Skip to content

Releases: OscarCardozoDev/QuycaServer

Quyca v2.2.0 — Refresh tokens, rate limiting y el cierre del path traversal

Choose a tag to compare

@OscarCardozoDev OscarCardozoDev released this 04 Sep 17:25

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-auth dedicado
  • Rate limiting por cuenta (AccountThrottlerGuard) en POST /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
  • /health ahora también hace PING a redis-auth, no solo SELECT 1 a 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-groups se guardea con canSee, 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

https://claude.ai/code/session_01NpSUXHLN5dK8hkB7QB2bgm

Quyca v2.1.0 — la landing nueva, el modo oscuro y el pipeline

Choose a tag to compare

@OscarCardozoDev OscarCardozoDev released this 31 Aug 04:08

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.ts deja de montar /api-docs y /api-docs-json cuando NODE_ENV=production. Antes publicaba el esquema OpenAPI completo sin autenticación.
  • GET /api/health — sonda nueva, pública, sin guards, corre SELECT 1 contra 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-lockfile sin fallback, imagen de producción construida y verificada en CI, deploy automático a master con needs: [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

Choose a tag to compare

@OscarCardozoDev OscarCardozoDev released this 27 Aug 00:40

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 con runWithoutTenant(), 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 por userType. Los seis roles: rector,
    coordinator, institutional, independent, student, self-taught.
  • userType queda 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.
  • SqlInjectionGuard global, 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 PENDING y REJECTED con 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: sameSite correcto 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).
  • Styles pasa a catálogo de plataforma por categoría (sin groupId ni institutionId).
  • Rutas privadas por grupo para obras y eventos, acotadas al tenant.

Infraestructura

  • Prisma 7 con @prisma/adapter-pg; PrismaService es 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-platform

El 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:data

Despué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 -d

env_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 es eslint "{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 include anidados, $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 devuelve false.
  • Álbumes con varias pistas (hoy: una canción por obra) — obsidian/Tareas/Musica-Lo-que-falta.md.

Usta Gallery Server v1.0.0

Choose a tag to compare

@OscarCardozoDev OscarCardozoDev released this 05 May 23:52
e6374a5

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)
Email 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).