Releases: OscarCardozoDev/QuycaServer
Release list
Quyca v2.2.0 — Refresh tokens, rate limiting y el cierre del path traversal
Refresh tokens, rate limiting por cuenta y el cierre del path traversal
Detalle completo: obsidian/versions/2.2.0.md en el repo raíz (Quyca).
Lo que entra
Backend
- Refresh tokens con rotación y detección de reuso, sobre un
redis-authdedicado - Rate limiting por cuenta (
AccountThrottlerGuard) enPOST /auth/login— por email, no por IP - Cierre de un path traversal real en la subida de archivos (
resolveFolder()+safeFileName()) - Validación de entradas: listas blancas, topes de tamaño, validación de URLs
/healthahora también hacePINGaredis-auth, no soloSELECT 1a Postgres
Frontend
- Módulo Tools: afinador y "Cantar con Guía"
- Refresh single-flight ante 401 concurrentes
- Rate limiting en nginx (
limit_req/limit_conn) /explore-groupsse guardea concanSee, cierra un agujero de autorización- Responsive completo del dashboard, selector de institución movido a Inicio, invitaciones a un modal
Breaking changes
Ninguno en la superficie de API — ningún endpoint cambió de ruta ni de forma de respuesta.
Sí hubo un requisito de infraestructura: REDIS_AUTH_URL es una variable nueva sin valor por
defecto, y el servicio redis-auth no se crea solo en un deploy (--no-deps). Se preparó a mano en
la VM antes de este release y quedó verificado (PING responde NOAUTH, contraseñas coincidentes).
Detalle en obsidian/versions/2.2.0.md §3 y §4.
Qué hacer para desplegar
Nada adicional — el pipeline (push a master → tests + build + deploy + verificación por curl) lo
hace solo, sobre una VM que ya tiene redis-auth preparado.
🤖 Generated with Claude Code
Quyca v2.1.0 — la landing nueva, el modo oscuro y el pipeline
Quyca v2.1.0 — backend
Segunda mitad del release conjunto v2.1.0 (frontend: landing nueva + modo oscuro). El titular de este lado es el pipeline: CI pasa de correr tests a compilar, construir la imagen de producción y desplegar solo por SSH con verificación por curl.
Qué entra
- Cupo gratis de plataforma de 1 a 5 (
FREE_PLATFORM_GROUPS) — el valor ya corría en producción editado a mano en el disco de la VM, sin commitear; se formalizó en código para que el deploy automático (reset --hard) no lo revirtiera en silencio. - Swagger fuera de producción —
main.tsdeja de montar/api-docsy/api-docs-jsoncuandoNODE_ENV=production. Antes publicaba el esquema OpenAPI completo sin autenticación. GET /api/health— sonda nueva, pública, sin guards, correSELECT 1contra Postgres. 200 = app y base responden; 503 = base caída.- Catálogo de estilos de arte reconstruido (se había partido en un merge).
- Pipeline:
bun install --frozen-lockfilesin fallback, imagen de producción construida y verificada en CI, deploy automático amasterconneeds: [tests, image].
Qué se rompe
Nada en el contrato de API. /api/api-docs y /api-docs-json pasan a dar 404 en producción — deliberado, no un bug. Sin migraciones, sin variables de entorno nuevas.
Qué hace falta para desplegar
Nada manual. El push a master dispara el job deploy (SSH + podman-compose + curl de verificación). Detalle en obsidian/versions/2.1.0.md y obsidian/Configuracion/Despliegue-Produccion.md.
Quyca V 2.0
Quyca Server 2.0
Merge: develop → master · 98 commits · 213 archivos · +43.341 / −3.069
UstaGallery deja de ser la galería de una universidad y pasa a ser Quyca, un SaaS
multi-tenant para instituciones educativas colombianas. La 1.x asumía una sola institución
en cada consulta; la 2.0 no puede asumir ninguna, y ese cambio toca el 80% del backend.
1. Lo que entra
Multi-tenancy (el cambio de fondo)
Aislamiento por fila con institutionId denormalizado. El filtro no lo escribe ningún
servicio: lo inyecta una extensión del cliente de Prisma en cada consulta.
| Pieza | Archivo | Qué hace |
|---|---|---|
| Contexto | src/tenant/tenant-context.ts |
AsyncLocalStorage con la institución del request |
| Middleware | src/tenant/tenant.middleware.ts |
Abre el store, una vez por request |
| Guard | src/tenant/tenant.guard.ts |
Resuelve slug → institución y exige membresía activa |
| Extensión | src/tenant/tenant.extension.ts |
Inyecta el where en los 9 modelos scoped |
| Cross-tenant | src/tenant/cross-tenant.guard.ts |
@AllowCrossTenant() para endpoints de super_admin |
- La institución activa viaja en el header
X-Institution-Slug, nunca en el JWT. - Falla cerrado: un modelo scoped consultado sin tenant resuelto tira 403. Lo público
se declara conrunWithoutTenant(), explícito y auditable. - Modelos scoped:
Groups,Events,Products,Classes,Schedule,Attendance,
ContentRequest,Lessons,Chapters. - Catálogos de plataforma (sin tenant):
GroupCategory,SubscriptionPlan,UserTypes,
Roles,Styles. - Modelos de bootstrap (filtro explícito obligatorio):
Institution,UserInstitution,
InstitutionInvitation— son los que resuelven el tenant, no pueden depender de él.
Reglas completas en obsidian/Arquitectura/Multitenancy.md; los internos, en obsidian/learn/.
Módulos nuevos
| Módulo | Endpoints | Para qué |
|---|---|---|
institutions |
/institutions, invitaciones, membresías |
Alta atómica de institución + rector, invitar, aceptar/rechazar, salir |
categories |
/categories, ContentRequest |
Catálogo global de categorías y solicitudes de contenido |
lessons + chapters |
/lessons, /lessons/:id/chapters |
Lecciones, capítulos, progreso y cola de revisión |
plans |
/subscription-plans |
Planes, features y límites por plan |
roles |
/roles |
Los seis contextRole de la plataforma |
La superficie HTTP pasa de 61 a 96 rutas.
Autorización
- Se autoriza por
@RequireContextRole(...)— el rol en la institución activa
(UserInstitution.contextRole), no poruserType. Los seis roles:rector,
coordinator,institutional,independent,student,self-taught. userTypequeda como identidad global (super_admin,institution,professor,user)
con UUIDs fijos. Usarlo para autorizar abría una escalada a SUPER_ADMIN — por eso se movió.FeatureGuard+@RequireFeature(...): la feature sale del plan de la institución.SqlInjectionGuardglobal, antes de cualquier controlador.
Seguridad (25 fixes)
Los de mayor impacto, todos con su plan en docs/superpowers/plans/:
- Registro sin autenticar que permitía autoasignarse
super_admin. - Alta directa de profesores (
POST /user/professor) — eliminada, ahora se invita. - Fugas cross-tenant en grupos, eventos, productos, invitaciones y capítulos.
- Portafolio público que exponía obras
PENDINGyREJECTEDcon el feedback del docente. - Enumeración de correos en el login (mensajes de error unificados).
Math.random()→crypto.randomInt()en los códigos de verificación.- Cookie de sesión:
sameSitecorrecto para cross-origin en producción.
Contenido y obras
- Audio en las obras (
Products.audioUrl): una pista por obra para la categoría música,
validada por firma de archivo y servida desde/audio. - Grupos con descripción, reglas, portada, baja lógica y límite por plan (
maxGroups). Stylespasa a catálogo de plataforma por categoría (singroupIdniinstitutionId).- Rutas privadas por grupo para obras y eventos, acotadas al tenant.
Infraestructura
- Prisma 7 con
@prisma/adapter-pg;PrismaServicees un Symbol que devuelve el cliente
ya extendido — ningún servicio puede obtener uno sin filtrar. - Correo transaccional con Resend (invitaciones con link y vencimiento a 3 días).
- CI en GitHub Actions: Postgres de servicio,
migrate deploy, seed y 402 tests en 56 suites. - Colección de Postman/Newman al día, incluida la suite de Lessons y Chapters.
2. Breaking changes
| # | Qué cambió | Qué se rompe | Qué hacer |
|---|---|---|---|
| 1 | Header X-Institution-Slug obligatorio |
Toda consulta a un modelo scoped sin header → 403 | El cliente manda el slug en cada request |
| 2 | POST /user/professor eliminado |
Alta directa de profesores | Invitar: POST /institutions/:id/invitations |
| 3 | GET /products/getGroup/:uid eliminado (era público y sin filtro de estado) |
Lecturas anónimas de obras de un grupo | GET /products/group/:uid con sesión y membresía |
| 4 | GET /styles/mine eliminado |
"Estilos de mi institución" ya no existe | GET /styles/all/:categoryId |
| 5 | Vocabulario de roles: admin/user/... → super_admin/institution/professor/user + los seis contextRole |
Cualquier check por userType |
Autorizar por contextRole |
| 6 | Category (enum) → tabla GroupCategory |
FKs y filtros por categoría | Correr prisma:migrate:data |
| 7 | Users no tiene institutionId |
Suponer una institución por usuario | El rol vive en UserInstitution |
| 8 | El login devuelve nextSteps en vez de tres booleanos |
Clientes que leían los flags | Leer nextSteps |
| 9 | Historial de migraciones squasheado en 20260807190410_init |
migrate deploy sobre una base 1.x |
Ver Despliegue |
3. Despliegue
Variables de entorno nuevas (env/production.env)
| Variable | Obligatoria | Para qué |
|---|---|---|
ID_SUPER_ADMIN, ID_INSTITUTION, ID_PROFESSOR, ID_USER |
sí | UUIDs fijos que deben coincidir con las filas de UserTypes |
RESEND_API_KEY, RESEND_EMAIL_FROM |
sí | Invitaciones y códigos por correo |
FRONTEND_URL |
recomendada | Links absolutos del correo. Sin ella cae a CORS_URL_FRONT |
SEMESTER_END_DATE |
sí | Fin del período académico |
DATABASE_URL, JWT_SECRET y CORS_URL_FRONT siguen igual.
Base de datos limpia
bun install
bun run prisma:migrate:prod # migrate deploy
bun run prisma:seed:static # UserTypes, Roles, planes, 5 categorías, quyca-platformEl seed es idempotente y es obligatorio: sin UserTypes con los UUIDs de las env vars,
la app arranca pero ningún alta funciona.
Base existente de la 1.x
El historial de migraciones fue reemplazado por un init squasheado, así que Prisma no
reconoce la base vieja. En orden:
# 1. Backup. No es opcional.
pg_dump "$DATABASE_URL" > backup-pre-2.0.sql
# 2. Marcar el init como aplicado (la base YA tiene esas tablas)
npx prisma migrate resolve --applied 20260807190410_init
# 3. Aplicar el resto
bun run prisma:migrate:prod
# 4. Sembrar catálogos
bun run prisma:seed:static
# 5. Migrar los datos: Category (enum) → GroupCategory, y poblar institutionId
bun run prisma:migrate:dataDespués del paso 5, verificar que no quedó ninguna fila scoped sin institución:
SELECT 'groups' t, count(*) FROM "Groups" WHERE "institutionId" IS NULL
UNION ALL SELECT 'products', count(*) FROM "Products" WHERE "institutionId" IS NULL
UNION ALL SELECT 'events', count(*) FROM "Events" WHERE "institutionId" IS NULL;Cualquier resultado distinto de 0 bloquea el despliegue: esas filas son invisibles para
la extensión de tenant y no las va a ver nadie.
Docker
docker-compose -f docker-compose.prod.yml up -denv_file se lee al crear el contenedor, no al arrancarlo: si cambian las variables,
--force-recreate, no restart.
4. Verificación
| Qué | Comando | Estado |
|---|---|---|
| Unit + integración | docker exec Quyca-Backend node node_modules/jest/bin/jest.js |
56 suites / 402 tests ✅ |
| CI | GitHub Actions, job jest |
verde con Postgres de servicio ✅ |
| API end-to-end | bun run test:api (Newman, servidor arriba) |
reporte en reports/ |
| Aislamiento | src/tenant/tenant-isolation.spec.ts |
10 modelos scoped, dos instituciones reales ✅ |
Nunca correr
bun run lint: el script eseslint "{src,apps,libs,test}/**/*.ts" --fix,
sin scope y con--fix. Reescribió ~70 archivos de una vez, incluido el cliente generado.
5. Lo que queda pendiente
- Postgres RLS. La extensión no cubre
includeanidados,$queryRaw, consultas directas
a tablas puente ni seeds. RLS es el cierre real de ese hueco. AuthContext.isAuthenticated()del frontend compara contra una clave literal en vez de
la de sesión: siempre devuelvefalse.- Álbumes con varias pistas (hoy: una canción por obra) —
obsidian/Tareas/Musica-Lo-que-falta.md.
Usta Gallery Server v1.0.0
UstaGallery v1.0.0
Plataforma de gestión de galería de arte para la Universidad Santo Tomás (Tunja).
Proyecto de grado. Backend NestJS + Bun + PostgreSQL. Frontend React 19 + Vite.
Módulos
Autenticación (/auth)
- Registro y login con JWT en cookie
HttpOnly - Tres roles:
admin,professor,student - Verificación de email por código de 6 dígitos
- Recuperación de contraseña por correo (Resend)
Usuarios (/user)
- Gestión de perfiles vinculados a credenciales
- Creación de estudiantes solo por profesores/admin
- Foto de perfil por usuario
Roles (/roles)
- Catálogo de tipos de usuario
- Validación de roles por UUID (variables de entorno)
Grupos (/groups)
- Profesores gestionan grupos de estudiantes
- Asignación y cambio de profesor por grupo
Obras / Productos (/products)
- Estudiantes crean obras con imágenes (base64)
- Flujo de aprobación: profesores/admin aprueban para galería pública
- Filtrado por estilos artísticos
Estilos artísticos (/styles)
- Catálogo de estilos con categorías
- Usados como filtro en obras y galería
Fotos (/photos)
- Imágenes almacenadas como base64 en DB
- Servidas como archivos estáticos desde
public/images/
Eventos (/events)
- Flujo de estados:
PENDING → APPROVED → COMPLETED / CANCELLED - Completado lazy: se marca al leer si
startDate < now - Tipos de foto:
HERO(portada),PROMO,MEMORY - Invitaciones a grupos externos → aceptar → participación
Horarios (/schedule)
- Slots semanales recurrentes
- Al crear, genera clases automáticamente hasta
SEMESTER_END_DATE
Clases (/classes)
- Clases generadas desde horarios o creadas manualmente
- Registro de asistencia con constraint único
[classId, userId]
Stack técnico
| Capa | Tecnología |
|---|---|
| Runtime | Bun |
| Framework | NestJS |
| Base de datos | PostgreSQL |
| ORM | Prisma (@prisma/adapter-pg) |
| Auth | JWT (cookie HttpOnly) |
| Resend | |
| Contenedores | Docker Compose |
| Documentación API | Swagger (/api-docs) |
| Tests API | Newman (Postman) |
Roles y permisos
| Rol | Capacidades principales |
|---|---|
admin |
Acceso completo |
professor |
Gestión de grupo, aprobación de obras, eventos, horarios |
student |
Crear obras, registrar asistencia |
| (sin auth) | Lectura de galería pública, eventos e info de autores |
Modelo de identidad
Credentials y Users son tablas separadas con el mismo UUID como PK.
Un usuario puede tener credenciales sin perfil (hasProfile: false).