diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4dd6ea6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +node_modules +dist +coverage +.tmp +.cache +.git +.gitignore +Dockerfile* +docker-compose* +.env +.env.* +.env.docker diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..335c209 --- /dev/null +++ b/.env.example @@ -0,0 +1,36 @@ +# .env que utliza la base de datos local MYSQL + +DB_NAME= +DB_USER= +DB_PASS= +DB_HOST= +DB_PORT= +DB_DIALECT= +DB_SSL=false + +PORT= +CORS_ORIGIN= + +JWT_SECRET= +JWT_EXPIRES= + +# .env.docker que utiliza una base de datos en la nuebe TIBD y que solo utilizamos en el docker-compose.yml + +NODE_ENV=development + +DB_NAME= +DB_USER= +DB_PASS= +DB_HOST= +DB_PORT= + +DB_DIALECT= + +DB_SSL=true +DB_SSL_CA_PATH= + + +PORT= +CORS_ORIGIN= +JWT_SECRET= +JWT_EXPIRES= diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..3fe2560 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,52 @@ +name: Build & Push Docker image + +on: + push: + branches: [ "develop", "main" ] # se ejecuta cuando hay push a develop o main + workflow_dispatch: # permite lanzarlo a mano desde Actions + +jobs: + docker: + runs-on: ubuntu-latest # VM Ubuntu donde corren los pasos + + steps: + - name: Checkout + uses: actions/checkout@v4 # baja el código del repo (lee el Dockerfile) + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + # Buildx = builder moderno de Docker (mejor caché) + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} # <- secret con "gema284" + password: ${{ secrets.DOCKERHUB_TOKEN }} # <- secret con tu access token + + - name: Choose tags + id: vars + shell: bash + run: | + REPO="${{ secrets.DOCKERHUB_USERNAME }}/codigo-abisal-server-api" + SHA="${GITHUB_SHA::7}" # commit corto (tag inmutable) + BR="${GITHUB_REF_NAME}" # nombre de la rama (develop o main) + + # develop -> :dev y :SHA + TAGS="$REPO:dev,$REPO:$SHA" + + # main -> :main y :SHA + if [ "$BR" = "main" ]; then + TAGS="$REPO:main,$REPO:$SHA" + fi + + echo "tags=$TAGS" >> "$GITHUB_OUTPUT" + + - name: Build & Push + uses: docker/build-push-action@v6 + with: + context: . # carpeta con tu Dockerfile (raíz) + file: ./Dockerfile # ruta al Dockerfile + push: true # construye y SUBE la imagen a Docker Hub + tags: ${{ steps.vars.outputs.tags }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore index a5a95fe..4e0eef9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,15 @@ node_modules .env .env.test -.DS_Store \ No newline at end of file +.DS_Store +dist +.env.docker + +# Certificados / claves +certs/*.pem +certs/*.key + +# Dumps y backups +*.sql +*.sql.gz +*.dump \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..387d0e2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,39 @@ +# ========= Etapa 1: dependencias de producción ========= +FROM node:20-alpine AS deps +WORKDIR /app +COPY package*.json ./ +RUN npm ci --omit=dev +# Comentario: +# - 'npm ci' instala exactamente lo que hay en package-lock. +# - '--omit=dev' deja solo deps de producción (más pequeño). + +# ========= Etapa 2: compilar TypeScript a dist/ ========= +FROM node:20-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm ci # aquí sí instalamos devDependencies (ts, tipos, etc.) +COPY tsconfig.json ./ +COPY src ./src +RUN npm run build # genera dist/app.js (coincide con tu package.json) + +# ========= Etapa 3: imagen final para ejecutar ========= +FROM node:20-alpine AS runner +ENV NODE_ENV=production +WORKDIR /app + +# Copiamos lo mínimo necesario para correr +COPY --from=deps /app/node_modules ./node_modules +COPY --from=builder /app/dist ./dist +COPY package*.json ./ + +# Copiamos el certificado de TiDB dentro de la imagen +COPY certs ./certs + +# Seguridad: no correr como root +RUN addgroup -S app && adduser -S app -G app +RUN chown -R app:app /app +USER app + +EXPOSE 8000 +# Muy importante: en Render se usa process.env.PORT automáticamente +CMD ["node", "dist/app.js"] diff --git a/app.ts b/app.ts deleted file mode 100644 index e69de29..0000000 diff --git a/controllers/.gitkeep b/controllers/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/database/.gitkeep b/database/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c5c6128 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,41 @@ +services: + + # mysql: // todo esto lo he comentado porque esta es la BD MYSQL que yo tenia en local ahora ya no la necesito la he echo en la nube + # image: mysql:8.4 + # container_name: mysql-abisal + # environment: + # - MYSQL_ROOT_PASSWORD=alba2005 # contraseña del root + # - MYSQL_DATABASE=abisal_app # base de datos que se crea al iniciar + # - MYSQL_USER=app # usuario normal (NO root) + # - MYSQL_PASSWORD=alba2005 # contraseña app + # ports: + # - "3307:3306" # mapeo externo (puedes dejarlo o quitarlo) + # volumes: + # - mysql_data:/var/lib/mysql + # healthcheck: + # test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + # interval: 10s + # timeout: 5s + # retries: 10 + + api: + # build: + # context: . + # dockerfile: Dockerfile // cuando se construye la imagen desde local ya no lo necesito la tengo en dockerhub ahora pongo: + container_name: abisal-api + platform: linux/amd64 + image: gema284/codigo-abisal-server-api:dev + env_file: .env.docker + # environment: + # - DB_HOST=mysql + ports: + - "8000:8000" + # depends_on: + # mysql: + # condition: service_healthy + # volumes: + # - ./certs/tidb-ca.pem:/app/certs/tidb-ca.pem:ro//este lo que quitado ahora porque lo he metido en la imagen dockerfile + +# volumes: + +# mysql_data:// este lo quito porque apuntaba a la BD local de docker diff --git a/jest.config.mjs b/jest.config.mjs new file mode 100644 index 0000000..2ffa364 --- /dev/null +++ b/jest.config.mjs @@ -0,0 +1,33 @@ + + +// 1) Exporto en ESM porque tu proyecto es ESM (type: module / NodeNext) +export default { + // 2) Preset de ts-jest para ESM + preset: "ts-jest/presets/default-esm", + + // 3) Entorno de Node + testEnvironment: "node", + + // 4) Fuerzo a ts-jest a ESM explícito + transform: { + "^.+\\.tsx?$": [ + "ts-jest", + { + useESM: true, + tsconfig: "./tsconfig.json" + } + ] + }, + + // 5) Trata .ts como módulos ESM + extensionsToTreatAsEsm: [".ts"], + + // 6) Arreglo común: si importas con sufijo .js en código TS, + // mapea a la ruta sin .js para que Jest lo resuelva bien. + moduleNameMapper: { + "^(\\.{1,2}/.*)\\.js$": "$1" + }, + setupFilesAfterEnv: ["/test/jest.setup.ts"], + // 7) Dónde están tus tests + testMatch: ["**/test/**/*.test.ts"] +}; diff --git a/models/.gitkeep b/models/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/package-lock.json b/package-lock.json index a60e0c5..4cc6b27 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,15 +10,29 @@ "license": "ISC", "dependencies": { "bcryptjs": "^3.0.2", - "express": "^5.1.0" + "cors": "^2.8.5", + "crypto": "^1.0.1", + "dotenv": "^17.2.2", + "express": "^5.1.0", + "express-validator": "^7.2.1", + "jsonwebtoken": "^9.0.2", + "mysql2": "^3.15.1", + "sequelize": "^6.37.7" }, "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/cors": "^2.8.19", "@types/express": "^5.0.3", "@types/jest": "^30.0.0", + "@types/jsonwebtoken": "^9.0.10", + "@types/node": "^24.5.2", "@types/supertest": "^6.0.3", - "jest": "^30.1.3", + "cross-env": "^10.1.0", + "jest": "^30.2.0", "supertest": "^7.1.4", - "ts-jest": "^29.4.4" + "ts-jest": "^29.4.4", + "tsx": "^4.20.6", + "typescript": "^5.9.3" } }, "node_modules/@babel/code-frame": { @@ -551,6 +565,455 @@ "tslib": "^2.4.0" } }, + "node_modules/@epic-web/invariant": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", + "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz", + "integrity": "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.10.tgz", + "integrity": "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.10.tgz", + "integrity": "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.10.tgz", + "integrity": "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.10.tgz", + "integrity": "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.10.tgz", + "integrity": "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.10.tgz", + "integrity": "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.10.tgz", + "integrity": "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.10.tgz", + "integrity": "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.10.tgz", + "integrity": "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.10.tgz", + "integrity": "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.10.tgz", + "integrity": "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.10.tgz", + "integrity": "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.10.tgz", + "integrity": "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.10.tgz", + "integrity": "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.10.tgz", + "integrity": "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.10.tgz", + "integrity": "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.10.tgz", + "integrity": "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.10.tgz", + "integrity": "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.10.tgz", + "integrity": "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.10.tgz", + "integrity": "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.10.tgz", + "integrity": "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.10.tgz", + "integrity": "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.10.tgz", + "integrity": "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.10.tgz", + "integrity": "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.10.tgz", + "integrity": "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -597,17 +1060,17 @@ } }, "node_modules/@jest/console": { - "version": "30.1.2", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.1.2.tgz", - "integrity": "sha512-BGMAxj8VRmoD0MoA/jo9alMXSRoqW8KPeqOfEo1ncxnRLatTBCpRoOwlwlEMdudp68Q6WSGwYrrLtTGOh8fLzw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.2.0.tgz", + "integrity": "sha512-+O1ifRjkvYIkBqASKWgLxrpEhQAAE7hY77ALLUufSk5717KfOShg6IbqLmdsLMPdUiFvA2kTs0R7YZy+l0IzZQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.0.5", + "@jest/types": "30.2.0", "@types/node": "*", "chalk": "^4.1.2", - "jest-message-util": "30.1.0", - "jest-util": "30.0.5", + "jest-message-util": "30.2.0", + "jest-util": "30.2.0", "slash": "^3.0.0" }, "engines": { @@ -615,39 +1078,39 @@ } }, "node_modules/@jest/core": { - "version": "30.1.3", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.1.3.tgz", - "integrity": "sha512-LIQz7NEDDO1+eyOA2ZmkiAyYvZuo6s1UxD/e2IHldR6D7UYogVq3arTmli07MkENLq6/3JEQjp0mA8rrHHJ8KQ==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.2.0.tgz", + "integrity": "sha512-03W6IhuhjqTlpzh/ojut/pDB2LPRygyWX8ExpgHtQA8H/3K7+1vKmcINx5UzeOX1se6YEsBsOHQ1CRzf3fOwTQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.1.2", + "@jest/console": "30.2.0", "@jest/pattern": "30.0.1", - "@jest/reporters": "30.1.3", - "@jest/test-result": "30.1.3", - "@jest/transform": "30.1.2", - "@jest/types": "30.0.5", + "@jest/reporters": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", "ci-info": "^4.2.0", "exit-x": "^0.2.2", "graceful-fs": "^4.2.11", - "jest-changed-files": "30.0.5", - "jest-config": "30.1.3", - "jest-haste-map": "30.1.0", - "jest-message-util": "30.1.0", + "jest-changed-files": "30.2.0", + "jest-config": "30.2.0", + "jest-haste-map": "30.2.0", + "jest-message-util": "30.2.0", "jest-regex-util": "30.0.1", - "jest-resolve": "30.1.3", - "jest-resolve-dependencies": "30.1.3", - "jest-runner": "30.1.3", - "jest-runtime": "30.1.3", - "jest-snapshot": "30.1.2", - "jest-util": "30.0.5", - "jest-validate": "30.1.0", - "jest-watcher": "30.1.3", + "jest-resolve": "30.2.0", + "jest-resolve-dependencies": "30.2.0", + "jest-runner": "30.2.0", + "jest-runtime": "30.2.0", + "jest-snapshot": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", + "jest-watcher": "30.2.0", "micromatch": "^4.0.8", - "pretty-format": "30.0.5", + "pretty-format": "30.2.0", "slash": "^3.0.0" }, "engines": { @@ -673,39 +1136,39 @@ } }, "node_modules/@jest/environment": { - "version": "30.1.2", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.1.2.tgz", - "integrity": "sha512-N8t1Ytw4/mr9uN28OnVf0SYE2dGhaIxOVYcwsf9IInBKjvofAjbFRvedvBBlyTYk2knbJTiEjEJ2PyyDIBnd9w==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.2.0.tgz", + "integrity": "sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==", "dev": true, "license": "MIT", "dependencies": { - "@jest/fake-timers": "30.1.2", - "@jest/types": "30.0.5", + "@jest/fake-timers": "30.2.0", + "@jest/types": "30.2.0", "@types/node": "*", - "jest-mock": "30.0.5" + "jest-mock": "30.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/expect": { - "version": "30.1.2", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.1.2.tgz", - "integrity": "sha512-tyaIExOwQRCxPCGNC05lIjWJztDwk2gPDNSDGg1zitXJJ8dC3++G/CRjE5mb2wQsf89+lsgAgqxxNpDLiCViTA==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.2.0.tgz", + "integrity": "sha512-V9yxQK5erfzx99Sf+7LbhBwNWEZ9eZay8qQ9+JSC0TrMR1pMDHLMY+BnVPacWU6Jamrh252/IKo4F1Xn/zfiqA==", "dev": true, "license": "MIT", "dependencies": { - "expect": "30.1.2", - "jest-snapshot": "30.1.2" + "expect": "30.2.0", + "jest-snapshot": "30.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/expect-utils": { - "version": "30.1.2", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.1.2.tgz", - "integrity": "sha512-HXy1qT/bfdjCv7iC336ExbqqYtZvljrV8odNdso7dWK9bSeHtLlvwWWC3YSybSPL03Gg5rug6WLCZAZFH72m0A==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.2.0.tgz", + "integrity": "sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==", "dev": true, "license": "MIT", "dependencies": { @@ -716,18 +1179,18 @@ } }, "node_modules/@jest/fake-timers": { - "version": "30.1.2", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.1.2.tgz", - "integrity": "sha512-Beljfv9AYkr9K+ETX9tvV61rJTY706BhBUtiaepQHeEGfe0DbpvUA5Z3fomwc5Xkhns6NWrcFDZn+72fLieUnA==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.2.0.tgz", + "integrity": "sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.0.5", + "@jest/types": "30.2.0", "@sinonjs/fake-timers": "^13.0.0", "@types/node": "*", - "jest-message-util": "30.1.0", - "jest-mock": "30.0.5", - "jest-util": "30.0.5" + "jest-message-util": "30.2.0", + "jest-mock": "30.2.0", + "jest-util": "30.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -744,16 +1207,16 @@ } }, "node_modules/@jest/globals": { - "version": "30.1.2", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.1.2.tgz", - "integrity": "sha512-teNTPZ8yZe3ahbYnvnVRDeOjr+3pu2uiAtNtrEsiMjVPPj+cXd5E/fr8BL7v/T7F31vYdEHrI5cC/2OoO/vM9A==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.2.0.tgz", + "integrity": "sha512-b63wmnKPaK+6ZZfpYhz9K61oybvbI1aMcIs80++JI1O1rR1vaxHUCNqo3ITu6NU0d4V34yZFoHMn/uoKr/Rwfw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.1.2", - "@jest/expect": "30.1.2", - "@jest/types": "30.0.5", - "jest-mock": "30.0.5" + "@jest/environment": "30.2.0", + "@jest/expect": "30.2.0", + "@jest/types": "30.2.0", + "jest-mock": "30.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -774,17 +1237,17 @@ } }, "node_modules/@jest/reporters": { - "version": "30.1.3", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.1.3.tgz", - "integrity": "sha512-VWEQmJWfXMOrzdFEOyGjUEOuVXllgZsoPtEHZzfdNz18RmzJ5nlR6kp8hDdY8dDS1yGOXAY7DHT+AOHIPSBV0w==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.2.0.tgz", + "integrity": "sha512-DRyW6baWPqKMa9CzeiBjHwjd8XeAyco2Vt8XbcLFjiwCOEKOvy82GJ8QQnJE9ofsxCMPjH4MfH8fCWIHHDKpAQ==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "30.1.2", - "@jest/test-result": "30.1.3", - "@jest/transform": "30.1.2", - "@jest/types": "30.0.5", + "@jest/console": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", "@jridgewell/trace-mapping": "^0.3.25", "@types/node": "*", "chalk": "^4.1.2", @@ -797,9 +1260,9 @@ "istanbul-lib-report": "^3.0.0", "istanbul-lib-source-maps": "^5.0.0", "istanbul-reports": "^3.1.3", - "jest-message-util": "30.1.0", - "jest-util": "30.0.5", - "jest-worker": "30.1.0", + "jest-message-util": "30.2.0", + "jest-util": "30.2.0", + "jest-worker": "30.2.0", "slash": "^3.0.0", "string-length": "^4.0.2", "v8-to-istanbul": "^9.0.1" @@ -830,13 +1293,13 @@ } }, "node_modules/@jest/snapshot-utils": { - "version": "30.1.2", - "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.1.2.tgz", - "integrity": "sha512-vHoMTpimcPSR7OxS2S0V1Cpg8eKDRxucHjoWl5u4RQcnxqQrV3avETiFpl8etn4dqxEGarBeHbIBety/f8mLXw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.2.0.tgz", + "integrity": "sha512-0aVxM3RH6DaiLcjj/b0KrIBZhSX1373Xci4l3cW5xiUWPctZ59zQ7jj4rqcJQ/Z8JuN/4wX3FpJSa3RssVvCug==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.0.5", + "@jest/types": "30.2.0", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "natural-compare": "^1.4.0" @@ -861,14 +1324,14 @@ } }, "node_modules/@jest/test-result": { - "version": "30.1.3", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.1.3.tgz", - "integrity": "sha512-P9IV8T24D43cNRANPPokn7tZh0FAFnYS2HIfi5vK18CjRkTDR9Y3e1BoEcAJnl4ghZZF4Ecda4M/k41QkvurEQ==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.2.0.tgz", + "integrity": "sha512-RF+Z+0CCHkARz5HT9mcQCBulb1wgCP3FBvl9VFokMX27acKphwyQsNuWH3c+ojd1LeWBLoTYoxF0zm6S/66mjg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.1.2", - "@jest/types": "30.0.5", + "@jest/console": "30.2.0", + "@jest/types": "30.2.0", "@types/istanbul-lib-coverage": "^2.0.6", "collect-v8-coverage": "^1.0.2" }, @@ -877,15 +1340,15 @@ } }, "node_modules/@jest/test-sequencer": { - "version": "30.1.3", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.1.3.tgz", - "integrity": "sha512-82J+hzC0qeQIiiZDThh+YUadvshdBswi5nuyXlEmXzrhw5ZQSRHeQ5LpVMD/xc8B3wPePvs6VMzHnntxL+4E3w==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.2.0.tgz", + "integrity": "sha512-wXKgU/lk8fKXMu/l5Hog1R61bL4q5GCdT6OJvdAFz1P+QrpoFuLU68eoKuVc4RbrTtNnTL5FByhWdLgOPSph+Q==", "dev": true, "license": "MIT", "dependencies": { - "@jest/test-result": "30.1.3", + "@jest/test-result": "30.2.0", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.1.0", + "jest-haste-map": "30.2.0", "slash": "^3.0.0" }, "engines": { @@ -893,23 +1356,23 @@ } }, "node_modules/@jest/transform": { - "version": "30.1.2", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.1.2.tgz", - "integrity": "sha512-UYYFGifSgfjujf1Cbd3iU/IQoSd6uwsj8XHj5DSDf5ERDcWMdJOPTkHWXj4U+Z/uMagyOQZ6Vne8C4nRIrCxqA==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.2.0.tgz", + "integrity": "sha512-XsauDV82o5qXbhalKxD7p4TZYYdwcaEXC77PPD2HixEFF+6YGppjrAAQurTl2ECWcEomHBMMNS9AH3kcCFx8jA==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.27.4", - "@jest/types": "30.0.5", + "@jest/types": "30.2.0", "@jridgewell/trace-mapping": "^0.3.25", - "babel-plugin-istanbul": "^7.0.0", + "babel-plugin-istanbul": "^7.0.1", "chalk": "^4.1.2", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.1.0", + "jest-haste-map": "30.2.0", "jest-regex-util": "30.0.1", - "jest-util": "30.0.5", + "jest-util": "30.2.0", "micromatch": "^4.0.8", "pirates": "^4.0.7", "slash": "^3.0.0", @@ -920,9 +1383,9 @@ } }, "node_modules/@jest/types": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.0.5.tgz", - "integrity": "sha512-aREYa3aku9SSnea4aX6bhKn4bgv3AXkgijoQgbYV3yvbiGt6z+MQ85+6mIhx9DsKW2BuB/cLR/A+tcMThx+KLQ==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", + "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", "dev": true, "license": "MIT", "dependencies": { @@ -1131,6 +1594,13 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -1159,6 +1629,25 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/express": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz", @@ -1229,6 +1718,17 @@ "pretty-format": "^30.0.0" } }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, "node_modules/@types/methods": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", @@ -1243,11 +1743,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.5.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.5.2.tgz", "integrity": "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.12.0" @@ -1321,6 +1826,12 @@ "@types/superagent": "^8.1.0" } }, + "node_modules/@types/validator": { + "version": "13.15.3", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.3.tgz", + "integrity": "sha512-7bcUmDyS6PN3EuD9SlGGOxM77F8WLVsrwkxyWxKnxzmXoequ6c7741QBrANq6htVRGOITJ7z72mTP6Z4XyuG+Q==", + "license": "MIT" + }, "node_modules/@types/yargs": { "version": "17.0.33", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", @@ -1710,17 +2221,26 @@ "dev": true, "license": "MIT" }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/babel-jest": { - "version": "30.1.2", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.1.2.tgz", - "integrity": "sha512-IQCus1rt9kaSh7PQxLYRY5NmkNrNlU2TpabzwV7T2jljnpdHOcmnYYv8QmE04Li4S3a2Lj8/yXyET5pBarPr6g==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz", + "integrity": "sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/transform": "30.1.2", + "@jest/transform": "30.2.0", "@types/babel__core": "^7.20.5", - "babel-plugin-istanbul": "^7.0.0", - "babel-preset-jest": "30.0.1", + "babel-plugin-istanbul": "^7.0.1", + "babel-preset-jest": "30.2.0", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "slash": "^3.0.0" @@ -1729,7 +2249,7 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { - "@babel/core": "^7.11.0" + "@babel/core": "^7.11.0 || ^8.0.0-0" } }, "node_modules/babel-plugin-istanbul": { @@ -1753,14 +2273,12 @@ } }, "node_modules/babel-plugin-jest-hoist": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.0.1.tgz", - "integrity": "sha512-zTPME3pI50NsFW8ZBaVIOeAxzEY7XHlmWeXXu9srI+9kNfzCUTy8MFan46xOGZY8NZThMqq+e3qZUKsvXbasnQ==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.2.0.tgz", + "integrity": "sha512-ftzhzSGMUnOzcCXd6WHdBGMyuwy15Wnn0iyyWGKgBDLxf9/s5ABuraCSpBX2uG0jUg4rqJnxsLc5+oYBqoxVaA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.27.3", "@types/babel__core": "^7.20.5" }, "engines": { @@ -1795,20 +2313,20 @@ } }, "node_modules/babel-preset-jest": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.0.1.tgz", - "integrity": "sha512-+YHejD5iTWI46cZmcc/YtX4gaKBtdqCHCVfuVinizVpbmyjO3zYmeuyFdfA8duRqQZfgCAMlsfmkVbJ+e2MAJw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.2.0.tgz", + "integrity": "sha512-US4Z3NOieAQumwFnYdUWKvUKh8+YSnS/gB3t6YBiz0bskpu7Pine8pPCheNxlPEW4wnUkma2a94YuW2q3guvCQ==", "dev": true, "license": "MIT", "dependencies": { - "babel-plugin-jest-hoist": "30.0.1", - "babel-preset-current-node-syntax": "^1.1.0" + "babel-plugin-jest-hoist": "30.2.0", + "babel-preset-current-node-syntax": "^1.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { - "@babel/core": "^7.11.0" + "@babel/core": "^7.11.0 || ^8.0.0-beta.1" } }, "node_modules/balanced-match": { @@ -1937,6 +2455,12 @@ "node-int64": "^0.4.0" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -2272,6 +2796,37 @@ "dev": true, "license": "MIT" }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/cross-env": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", + "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@epic-web/invariant": "^1.0.0", + "cross-spawn": "^7.0.6" + }, + "bin": { + "cross-env": "dist/bin/cross-env.js", + "cross-env-shell": "dist/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2287,6 +2842,13 @@ "node": ">= 8" } }, + "node_modules/crypto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/crypto/-/crypto-1.0.1.tgz", + "integrity": "sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig==", + "deprecated": "This package is no longer supported. It's now a built-in Node module. If you've depended on crypto, you should switch to the one that's built-in.", + "license": "ISC" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2339,6 +2901,15 @@ "node": ">=0.4.0" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -2369,6 +2940,24 @@ "wrappy": "1" } }, + "node_modules/dotenv": { + "version": "17.2.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.2.tgz", + "integrity": "sha512-Sf2LSQP+bOlhKWWyhFsn0UsfdK/kCWRv1iuA2gXAwt3dyNabr6QSj00I2V10pidqz69soatm9ZwZvpQMTIOd5Q==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dottie": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/dottie/-/dottie-2.0.6.tgz", + "integrity": "sha512-iGCHkfUc5kFekGiqhe8B/mdaurD+lakO9txNnTvKtA6PISrw86LgqHvRzWYPyoE2Ph5aMIrCw9/uko6XHTKCwA==", + "license": "MIT" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2390,6 +2979,15 @@ "dev": true, "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -2488,6 +3086,48 @@ "node": ">= 0.4" } }, + "node_modules/esbuild": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz", + "integrity": "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.10", + "@esbuild/android-arm": "0.25.10", + "@esbuild/android-arm64": "0.25.10", + "@esbuild/android-x64": "0.25.10", + "@esbuild/darwin-arm64": "0.25.10", + "@esbuild/darwin-x64": "0.25.10", + "@esbuild/freebsd-arm64": "0.25.10", + "@esbuild/freebsd-x64": "0.25.10", + "@esbuild/linux-arm": "0.25.10", + "@esbuild/linux-arm64": "0.25.10", + "@esbuild/linux-ia32": "0.25.10", + "@esbuild/linux-loong64": "0.25.10", + "@esbuild/linux-mips64el": "0.25.10", + "@esbuild/linux-ppc64": "0.25.10", + "@esbuild/linux-riscv64": "0.25.10", + "@esbuild/linux-s390x": "0.25.10", + "@esbuild/linux-x64": "0.25.10", + "@esbuild/netbsd-arm64": "0.25.10", + "@esbuild/netbsd-x64": "0.25.10", + "@esbuild/openbsd-arm64": "0.25.10", + "@esbuild/openbsd-x64": "0.25.10", + "@esbuild/openharmony-arm64": "0.25.10", + "@esbuild/sunos-x64": "0.25.10", + "@esbuild/win32-arm64": "0.25.10", + "@esbuild/win32-ia32": "0.25.10", + "@esbuild/win32-x64": "0.25.10" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -2579,18 +3219,18 @@ } }, "node_modules/expect": { - "version": "30.1.2", - "resolved": "https://registry.npmjs.org/expect/-/expect-30.1.2.tgz", - "integrity": "sha512-xvHszRavo28ejws8FpemjhwswGj4w/BetHIL8cU49u4sGyXDw2+p3YbeDbj6xzlxi6kWTjIRSTJ+9sNXPnF0Zg==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.2.0.tgz", + "integrity": "sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/expect-utils": "30.1.2", + "@jest/expect-utils": "30.2.0", "@jest/get-type": "30.1.0", - "jest-matcher-utils": "30.1.2", - "jest-message-util": "30.1.0", - "jest-mock": "30.0.5", - "jest-util": "30.0.5" + "jest-matcher-utils": "30.2.0", + "jest-message-util": "30.2.0", + "jest-mock": "30.2.0", + "jest-util": "30.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -2638,6 +3278,28 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-validator": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.2.1.tgz", + "integrity": "sha512-CjNE6aakfpuwGaHQZ3m8ltCG2Qvivd7RHtVMS/6nVxOM7xVGqr4bhflsm4+N5FP5zI7Zxp+Hae+9RE+o8e3ZOQ==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21", + "validator": "~13.12.0" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/express-validator/node_modules/validator": { + "version": "13.12.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz", + "integrity": "sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -2830,6 +3492,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -2910,6 +3581,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-tsconfig": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", + "integrity": "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/glob": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", @@ -3106,6 +3790,15 @@ "node": ">=0.8.19" } }, + "node_modules/inflection": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/inflection/-/inflection-1.13.4.tgz", + "integrity": "sha512-6I/HUDeYFfuNCVS3td055BaXBwKYuzw7K3ExVMStBowKo9oOAMJIXIHvdyR3iboTCp1b+1i5DSkIZTcwIktuDw==", + "engines": [ + "node >= 0.4.0" + ], + "license": "MIT" + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -3176,6 +3869,12 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "license": "MIT" + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -3297,16 +3996,16 @@ } }, "node_modules/jest": { - "version": "30.1.3", - "resolved": "https://registry.npmjs.org/jest/-/jest-30.1.3.tgz", - "integrity": "sha512-Ry+p2+NLk6u8Agh5yVqELfUJvRfV51hhVBRIB5yZPY7mU0DGBmOuFG5GebZbMbm86cdQNK0fhJuDX8/1YorISQ==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.2.0.tgz", + "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", "dependencies": { - "@jest/core": "30.1.3", - "@jest/types": "30.0.5", + "@jest/core": "30.2.0", + "@jest/types": "30.2.0", "import-local": "^3.2.0", - "jest-cli": "30.1.3" + "jest-cli": "30.2.0" }, "bin": { "jest": "bin/jest.js" @@ -3324,14 +4023,14 @@ } }, "node_modules/jest-changed-files": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.0.5.tgz", - "integrity": "sha512-bGl2Ntdx0eAwXuGpdLdVYVr5YQHnSZlQ0y9HVDu565lCUAe9sj6JOtBbMmBBikGIegne9piDDIOeiLVoqTkz4A==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.2.0.tgz", + "integrity": "sha512-L8lR1ChrRnSdfeOvTrwZMlnWV8G/LLjQ0nG9MBclwWZidA2N5FviRki0Bvh20WRMOX31/JYvzdqTJrk5oBdydQ==", "dev": true, "license": "MIT", "dependencies": { "execa": "^5.1.1", - "jest-util": "30.0.5", + "jest-util": "30.2.0", "p-limit": "^3.1.0" }, "engines": { @@ -3339,29 +4038,29 @@ } }, "node_modules/jest-circus": { - "version": "30.1.3", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.1.3.tgz", - "integrity": "sha512-Yf3dnhRON2GJT4RYzM89t/EXIWNxKTpWTL9BfF3+geFetWP4XSvJjiU1vrWplOiUkmq8cHLiwuhz+XuUp9DscA==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.2.0.tgz", + "integrity": "sha512-Fh0096NC3ZkFx05EP2OXCxJAREVxj1BcW/i6EWqqymcgYKWjyyDpral3fMxVcHXg6oZM7iULer9wGRFvfpl+Tg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.1.2", - "@jest/expect": "30.1.2", - "@jest/test-result": "30.1.3", - "@jest/types": "30.0.5", + "@jest/environment": "30.2.0", + "@jest/expect": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/types": "30.2.0", "@types/node": "*", "chalk": "^4.1.2", "co": "^4.6.0", "dedent": "^1.6.0", "is-generator-fn": "^2.1.0", - "jest-each": "30.1.0", - "jest-matcher-utils": "30.1.2", - "jest-message-util": "30.1.0", - "jest-runtime": "30.1.3", - "jest-snapshot": "30.1.2", - "jest-util": "30.0.5", + "jest-each": "30.2.0", + "jest-matcher-utils": "30.2.0", + "jest-message-util": "30.2.0", + "jest-runtime": "30.2.0", + "jest-snapshot": "30.2.0", + "jest-util": "30.2.0", "p-limit": "^3.1.0", - "pretty-format": "30.0.5", + "pretty-format": "30.2.0", "pure-rand": "^7.0.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" @@ -3371,21 +4070,21 @@ } }, "node_modules/jest-cli": { - "version": "30.1.3", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.1.3.tgz", - "integrity": "sha512-G8E2Ol3OKch1DEeIBl41NP7OiC6LBhfg25Btv+idcusmoUSpqUkbrneMqbW9lVpI/rCKb/uETidb7DNteheuAQ==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.2.0.tgz", + "integrity": "sha512-Os9ukIvADX/A9sLt6Zse3+nmHtHaE6hqOsjQtNiugFTbKRHYIYtZXNGNK9NChseXy7djFPjndX1tL0sCTlfpAA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/core": "30.1.3", - "@jest/test-result": "30.1.3", - "@jest/types": "30.0.5", + "@jest/core": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/types": "30.2.0", "chalk": "^4.1.2", "exit-x": "^0.2.2", "import-local": "^3.2.0", - "jest-config": "30.1.3", - "jest-util": "30.0.5", - "jest-validate": "30.1.0", + "jest-config": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", "yargs": "^17.7.2" }, "bin": { @@ -3404,34 +4103,34 @@ } }, "node_modules/jest-config": { - "version": "30.1.3", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.1.3.tgz", - "integrity": "sha512-M/f7gqdQEPgZNA181Myz+GXCe8jXcJsGjCMXUzRj22FIXsZOyHNte84e0exntOvdPaeh9tA0w+B8qlP2fAezfw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.2.0.tgz", + "integrity": "sha512-g4WkyzFQVWHtu6uqGmQR4CQxz/CH3yDSlhzXMWzNjDx843gYjReZnMRanjRCq5XZFuQrGDxgUaiYWE8BRfVckA==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.27.4", "@jest/get-type": "30.1.0", "@jest/pattern": "30.0.1", - "@jest/test-sequencer": "30.1.3", - "@jest/types": "30.0.5", - "babel-jest": "30.1.2", + "@jest/test-sequencer": "30.2.0", + "@jest/types": "30.2.0", + "babel-jest": "30.2.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "deepmerge": "^4.3.1", "glob": "^10.3.10", "graceful-fs": "^4.2.11", - "jest-circus": "30.1.3", - "jest-docblock": "30.0.1", - "jest-environment-node": "30.1.2", + "jest-circus": "30.2.0", + "jest-docblock": "30.2.0", + "jest-environment-node": "30.2.0", "jest-regex-util": "30.0.1", - "jest-resolve": "30.1.3", - "jest-runner": "30.1.3", - "jest-util": "30.0.5", - "jest-validate": "30.1.0", + "jest-resolve": "30.2.0", + "jest-runner": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", "micromatch": "^4.0.8", "parse-json": "^5.2.0", - "pretty-format": "30.0.5", + "pretty-format": "30.2.0", "slash": "^3.0.0", "strip-json-comments": "^3.1.1" }, @@ -3456,25 +4155,25 @@ } }, "node_modules/jest-diff": { - "version": "30.1.2", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.1.2.tgz", - "integrity": "sha512-4+prq+9J61mOVXCa4Qp8ZjavdxzrWQXrI80GNxP8f4tkI2syPuPrJgdRPZRrfUTRvIoUwcmNLbqEJy9W800+NQ==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz", + "integrity": "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==", "dev": true, "license": "MIT", "dependencies": { "@jest/diff-sequences": "30.0.1", "@jest/get-type": "30.1.0", "chalk": "^4.1.2", - "pretty-format": "30.0.5" + "pretty-format": "30.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-docblock": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.0.1.tgz", - "integrity": "sha512-/vF78qn3DYphAaIc3jy4gA7XSAz167n9Bm/wn/1XhTLW7tTBIzXtCJpb/vcmc73NIIeeohCbdL94JasyXUZsGA==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.2.0.tgz", + "integrity": "sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA==", "dev": true, "license": "MIT", "dependencies": { @@ -3485,56 +4184,56 @@ } }, "node_modules/jest-each": { - "version": "30.1.0", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.1.0.tgz", - "integrity": "sha512-A+9FKzxPluqogNahpCv04UJvcZ9B3HamqpDNWNKDjtxVRYB8xbZLFuCr8JAJFpNp83CA0anGQFlpQna9Me+/tQ==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.2.0.tgz", + "integrity": "sha512-lpWlJlM7bCUf1mfmuqTA8+j2lNURW9eNafOy99knBM01i5CQeY5UH1vZjgT9071nDJac1M4XsbyI44oNOdhlDQ==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", - "@jest/types": "30.0.5", + "@jest/types": "30.2.0", "chalk": "^4.1.2", - "jest-util": "30.0.5", - "pretty-format": "30.0.5" + "jest-util": "30.2.0", + "pretty-format": "30.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-environment-node": { - "version": "30.1.2", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.1.2.tgz", - "integrity": "sha512-w8qBiXtqGWJ9xpJIA98M0EIoq079GOQRQUyse5qg1plShUCQ0Ek1VTTcczqKrn3f24TFAgFtT+4q3aOXvjbsuA==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.2.0.tgz", + "integrity": "sha512-ElU8v92QJ9UrYsKrxDIKCxu6PfNj4Hdcktcn0JX12zqNdqWHB0N+hwOnnBBXvjLd2vApZtuLUGs1QSY+MsXoNA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.1.2", - "@jest/fake-timers": "30.1.2", - "@jest/types": "30.0.5", + "@jest/environment": "30.2.0", + "@jest/fake-timers": "30.2.0", + "@jest/types": "30.2.0", "@types/node": "*", - "jest-mock": "30.0.5", - "jest-util": "30.0.5", - "jest-validate": "30.1.0" + "jest-mock": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-haste-map": { - "version": "30.1.0", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.1.0.tgz", - "integrity": "sha512-JLeM84kNjpRkggcGpQLsV7B8W4LNUWz7oDNVnY1Vjj22b5/fAb3kk3htiD+4Na8bmJmjJR7rBtS2Rmq/NEcADg==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.2.0.tgz", + "integrity": "sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.0.5", + "@jest/types": "30.2.0", "@types/node": "*", "anymatch": "^3.1.3", "fb-watchman": "^2.0.2", "graceful-fs": "^4.2.11", "jest-regex-util": "30.0.1", - "jest-util": "30.0.5", - "jest-worker": "30.1.0", + "jest-util": "30.2.0", + "jest-worker": "30.2.0", "micromatch": "^4.0.8", "walker": "^1.0.8" }, @@ -3546,49 +4245,49 @@ } }, "node_modules/jest-leak-detector": { - "version": "30.1.0", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.1.0.tgz", - "integrity": "sha512-AoFvJzwxK+4KohH60vRuHaqXfWmeBATFZpzpmzNmYTtmRMiyGPVhkXpBqxUQunw+dQB48bDf4NpUs6ivVbRv1g==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.2.0.tgz", + "integrity": "sha512-M6jKAjyzjHG0SrQgwhgZGy9hFazcudwCNovY/9HPIicmNSBuockPSedAP9vlPK6ONFJ1zfyH/M2/YYJxOz5cdQ==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", - "pretty-format": "30.0.5" + "pretty-format": "30.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-matcher-utils": { - "version": "30.1.2", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.1.2.tgz", - "integrity": "sha512-7ai16hy4rSbDjvPTuUhuV8nyPBd6EX34HkBsBcBX2lENCuAQ0qKCPb/+lt8OSWUa9WWmGYLy41PrEzkwRwoGZQ==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.2.0.tgz", + "integrity": "sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", "chalk": "^4.1.2", - "jest-diff": "30.1.2", - "pretty-format": "30.0.5" + "jest-diff": "30.2.0", + "pretty-format": "30.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-message-util": { - "version": "30.1.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.1.0.tgz", - "integrity": "sha512-HizKDGG98cYkWmaLUHChq4iN+oCENohQLb7Z5guBPumYs+/etonmNFlg1Ps6yN9LTPyZn+M+b/9BbnHx3WTMDg==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", + "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@jest/types": "30.0.5", + "@jest/types": "30.2.0", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "micromatch": "^4.0.8", - "pretty-format": "30.0.5", + "pretty-format": "30.2.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" }, @@ -3597,15 +4296,15 @@ } }, "node_modules/jest-mock": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.0.5.tgz", - "integrity": "sha512-Od7TyasAAQX/6S+QCbN6vZoWOMwlTtzzGuxJku1GhGanAjz9y+QsQkpScDmETvdc9aSXyJ/Op4rhpMYBWW91wQ==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.2.0.tgz", + "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.0.5", + "@jest/types": "30.2.0", "@types/node": "*", - "jest-util": "30.0.5" + "jest-util": "30.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -3640,18 +4339,18 @@ } }, "node_modules/jest-resolve": { - "version": "30.1.3", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.1.3.tgz", - "integrity": "sha512-DI4PtTqzw9GwELFS41sdMK32Ajp3XZQ8iygeDMWkxlRhm7uUTOFSZFVZABFuxr0jvspn8MAYy54NxZCsuCTSOw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.2.0.tgz", + "integrity": "sha512-TCrHSxPlx3tBY3hWNtRQKbtgLhsXa1WmbJEqBlTBrGafd5fiQFByy2GNCEoGR+Tns8d15GaL9cxEzKOO3GEb2A==", "dev": true, "license": "MIT", "dependencies": { "chalk": "^4.1.2", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.1.0", + "jest-haste-map": "30.2.0", "jest-pnp-resolver": "^1.2.3", - "jest-util": "30.0.5", - "jest-validate": "30.1.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", "slash": "^3.0.0", "unrs-resolver": "^1.7.11" }, @@ -3660,46 +4359,46 @@ } }, "node_modules/jest-resolve-dependencies": { - "version": "30.1.3", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.1.3.tgz", - "integrity": "sha512-DNfq3WGmuRyHRHfEet+Zm3QOmVFtIarUOQHHryKPc0YL9ROfgWZxl4+aZq/VAzok2SS3gZdniP+dO4zgo59hBg==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.2.0.tgz", + "integrity": "sha512-xTOIGug/0RmIe3mmCqCT95yO0vj6JURrn1TKWlNbhiAefJRWINNPgwVkrVgt/YaerPzY3iItufd80v3lOrFJ2w==", "dev": true, "license": "MIT", "dependencies": { "jest-regex-util": "30.0.1", - "jest-snapshot": "30.1.2" + "jest-snapshot": "30.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-runner": { - "version": "30.1.3", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.1.3.tgz", - "integrity": "sha512-dd1ORcxQraW44Uz029TtXj85W11yvLpDuIzNOlofrC8GN+SgDlgY4BvyxJiVeuabA1t6idjNbX59jLd2oplOGQ==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.2.0.tgz", + "integrity": "sha512-PqvZ2B2XEyPEbclp+gV6KO/F1FIFSbIwewRgmROCMBo/aZ6J1w8Qypoj2pEOcg3G2HzLlaP6VUtvwCI8dM3oqQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.1.2", - "@jest/environment": "30.1.2", - "@jest/test-result": "30.1.3", - "@jest/transform": "30.1.2", - "@jest/types": "30.0.5", + "@jest/console": "30.2.0", + "@jest/environment": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", "@types/node": "*", "chalk": "^4.1.2", "emittery": "^0.13.1", "exit-x": "^0.2.2", "graceful-fs": "^4.2.11", - "jest-docblock": "30.0.1", - "jest-environment-node": "30.1.2", - "jest-haste-map": "30.1.0", - "jest-leak-detector": "30.1.0", - "jest-message-util": "30.1.0", - "jest-resolve": "30.1.3", - "jest-runtime": "30.1.3", - "jest-util": "30.0.5", - "jest-watcher": "30.1.3", - "jest-worker": "30.1.0", + "jest-docblock": "30.2.0", + "jest-environment-node": "30.2.0", + "jest-haste-map": "30.2.0", + "jest-leak-detector": "30.2.0", + "jest-message-util": "30.2.0", + "jest-resolve": "30.2.0", + "jest-runtime": "30.2.0", + "jest-util": "30.2.0", + "jest-watcher": "30.2.0", + "jest-worker": "30.2.0", "p-limit": "^3.1.0", "source-map-support": "0.5.13" }, @@ -3708,32 +4407,32 @@ } }, "node_modules/jest-runtime": { - "version": "30.1.3", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.1.3.tgz", - "integrity": "sha512-WS8xgjuNSphdIGnleQcJ3AKE4tBKOVP+tKhCD0u+Tb2sBmsU8DxfbBpZX7//+XOz81zVs4eFpJQwBNji2Y07DA==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.2.0.tgz", + "integrity": "sha512-p1+GVX/PJqTucvsmERPMgCPvQJpFt4hFbM+VN3n8TMo47decMUcJbt+rgzwrEme0MQUA/R+1de2axftTHkKckg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.1.2", - "@jest/fake-timers": "30.1.2", - "@jest/globals": "30.1.2", + "@jest/environment": "30.2.0", + "@jest/fake-timers": "30.2.0", + "@jest/globals": "30.2.0", "@jest/source-map": "30.0.1", - "@jest/test-result": "30.1.3", - "@jest/transform": "30.1.2", - "@jest/types": "30.0.5", + "@jest/test-result": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", "@types/node": "*", "chalk": "^4.1.2", "cjs-module-lexer": "^2.1.0", "collect-v8-coverage": "^1.0.2", "glob": "^10.3.10", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.1.0", - "jest-message-util": "30.1.0", - "jest-mock": "30.0.5", + "jest-haste-map": "30.2.0", + "jest-message-util": "30.2.0", + "jest-mock": "30.2.0", "jest-regex-util": "30.0.1", - "jest-resolve": "30.1.3", - "jest-snapshot": "30.1.2", - "jest-util": "30.0.5", + "jest-resolve": "30.2.0", + "jest-snapshot": "30.2.0", + "jest-util": "30.2.0", "slash": "^3.0.0", "strip-bom": "^4.0.0" }, @@ -3742,9 +4441,9 @@ } }, "node_modules/jest-snapshot": { - "version": "30.1.2", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.1.2.tgz", - "integrity": "sha512-4q4+6+1c8B6Cy5pGgFvjDy/Pa6VYRiGu0yQafKkJ9u6wQx4G5PqI2QR6nxTl43yy7IWsINwz6oT4o6tD12a8Dg==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.2.0.tgz", + "integrity": "sha512-5WEtTy2jXPFypadKNpbNkZ72puZCa6UjSr/7djeecHWOu7iYhSXSnHScT8wBz3Rn8Ena5d5RYRcsyKIeqG1IyA==", "dev": true, "license": "MIT", "dependencies": { @@ -3753,20 +4452,20 @@ "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/types": "^7.27.3", - "@jest/expect-utils": "30.1.2", + "@jest/expect-utils": "30.2.0", "@jest/get-type": "30.1.0", - "@jest/snapshot-utils": "30.1.2", - "@jest/transform": "30.1.2", - "@jest/types": "30.0.5", - "babel-preset-current-node-syntax": "^1.1.0", + "@jest/snapshot-utils": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", + "babel-preset-current-node-syntax": "^1.2.0", "chalk": "^4.1.2", - "expect": "30.1.2", + "expect": "30.2.0", "graceful-fs": "^4.2.11", - "jest-diff": "30.1.2", - "jest-matcher-utils": "30.1.2", - "jest-message-util": "30.1.0", - "jest-util": "30.0.5", - "pretty-format": "30.0.5", + "jest-diff": "30.2.0", + "jest-matcher-utils": "30.2.0", + "jest-message-util": "30.2.0", + "jest-util": "30.2.0", + "pretty-format": "30.2.0", "semver": "^7.7.2", "synckit": "^0.11.8" }, @@ -3788,13 +4487,13 @@ } }, "node_modules/jest-util": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.5.tgz", - "integrity": "sha512-pvyPWssDZR0FlfMxCBoc0tvM8iUEskaRFALUtGQYzVEAqisAztmy+R8LnU14KT4XA0H/a5HMVTXat1jLne010g==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", + "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.0.5", + "@jest/types": "30.2.0", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", @@ -3819,18 +4518,18 @@ } }, "node_modules/jest-validate": { - "version": "30.1.0", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.1.0.tgz", - "integrity": "sha512-7P3ZlCFW/vhfQ8pE7zW6Oi4EzvuB4sgR72Q1INfW9m0FGo0GADYlPwIkf4CyPq7wq85g+kPMtPOHNAdWHeBOaA==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.2.0.tgz", + "integrity": "sha512-FBGWi7dP2hpdi8nBoWxSsLvBFewKAg0+uSQwBaof4Y4DPgBabXgpSYC5/lR7VmnIlSpASmCi/ntRWPbv7089Pw==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", - "@jest/types": "30.0.5", + "@jest/types": "30.2.0", "camelcase": "^6.3.0", "chalk": "^4.1.2", "leven": "^3.1.0", - "pretty-format": "30.0.5" + "pretty-format": "30.2.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -3850,19 +4549,19 @@ } }, "node_modules/jest-watcher": { - "version": "30.1.3", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.1.3.tgz", - "integrity": "sha512-6jQUZCP1BTL2gvG9E4YF06Ytq4yMb4If6YoQGRR6PpjtqOXSP3sKe2kqwB6SQ+H9DezOfZaSLnmka1NtGm3fCQ==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.2.0.tgz", + "integrity": "sha512-PYxa28dxJ9g777pGm/7PrbnMeA0Jr7osHP9bS7eJy9DuAjMgdGtxgf0uKMyoIsTWAkIbUW5hSDdJ3urmgXBqxg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/test-result": "30.1.3", - "@jest/types": "30.0.5", + "@jest/test-result": "30.2.0", + "@jest/types": "30.2.0", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", "emittery": "^0.13.1", - "jest-util": "30.0.5", + "jest-util": "30.2.0", "string-length": "^4.0.2" }, "engines": { @@ -3870,15 +4569,15 @@ } }, "node_modules/jest-worker": { - "version": "30.1.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.1.0.tgz", - "integrity": "sha512-uvWcSjlwAAgIu133Tt77A05H7RIk3Ho8tZL50bQM2AkvLdluw9NG48lRCl3Dt+MOH719n/0nnb5YxUwcuJiKRA==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.2.0.tgz", + "integrity": "sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", "@ungap/structured-clone": "^1.3.0", - "jest-util": "30.0.5", + "jest-util": "30.2.0", "merge-stream": "^2.0.0", "supports-color": "^8.1.1" }, @@ -3956,6 +4655,61 @@ "node": ">=6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jwa": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", + "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -3986,6 +4740,48 @@ "node": ">=8" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -3993,6 +4789,18 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -4003,6 +4811,21 @@ "yallist": "^3.0.2" } }, + "node_modules/lru.min": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.2.tgz", + "integrity": "sha512-Nv9KddBcQSlQopmBHXSsZVY5xsdlZkdH/Iey0BlcBYggMd4two7cZnKOK9vmy3nY0O5RGH99z1PCeTpPqszUYg==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -4190,12 +5013,90 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/moment-timezone": { + "version": "0.5.48", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz", + "integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==", + "license": "MIT", + "dependencies": { + "moment": "^2.29.4" + }, + "engines": { + "node": "*" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/mysql2": { + "version": "3.15.1", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.1.tgz", + "integrity": "sha512-WZMIRZstT2MFfouEaDz/AGFnGi1A2GwaDe7XvKTdRJEYiAHbOrh4S3d8KFmQeh11U85G+BFjIvS1Di5alusZsw==", + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.1", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.0", + "long": "^5.2.1", + "lru.min": "^1.0.0", + "named-placeholders": "^1.1.3", + "seq-queue": "^0.0.5", + "sqlstring": "^2.3.2" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/mysql2/node_modules/iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.3.tgz", + "integrity": "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==", + "license": "MIT", + "dependencies": { + "lru-cache": "^7.14.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/named-placeholders/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/napi-postinstall": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.3.tgz", @@ -4272,6 +5173,15 @@ "node": ">=8" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -4475,6 +5385,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pg-connection-string": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz", + "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==", + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4519,9 +5435,9 @@ } }, "node_modules/pretty-format": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.5.tgz", - "integrity": "sha512-D1tKtYvByrBkFLe2wHJl2bwMJIiT8rW+XA+TiataH79/FszLQMrpGEvzUVkzPau7OCO0Qnrhpe87PqtOAIB8Yw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", + "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", "dev": true, "license": "MIT", "dependencies": { @@ -4671,6 +5587,22 @@ "node": ">=8" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/retry-as-promised": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/retry-as-promised/-/retry-as-promised-7.1.1.tgz", + "integrity": "sha512-hMD7odLOt3LkTjcif8aRZqi/hybjpLNgSk5oF5FCowfCjok6LukpN2bDX7R5wDmbgBQFn7YoBxSagmtXHaJYJw==", + "license": "MIT" + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -4745,6 +5677,94 @@ "node": ">= 18" } }, + "node_modules/seq-queue": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==" + }, + "node_modules/sequelize": { + "version": "6.37.7", + "resolved": "https://registry.npmjs.org/sequelize/-/sequelize-6.37.7.tgz", + "integrity": "sha512-mCnh83zuz7kQxxJirtFD7q6Huy6liPanI67BSlbzSYgVNl5eXVdE2CN1FuAeZwG1SNpGsNRCV+bJAVVnykZAFA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/sequelize" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.8", + "@types/validator": "^13.7.17", + "debug": "^4.3.4", + "dottie": "^2.0.6", + "inflection": "^1.13.4", + "lodash": "^4.17.21", + "moment": "^2.29.4", + "moment-timezone": "^0.5.43", + "pg-connection-string": "^2.6.1", + "retry-as-promised": "^7.0.4", + "semver": "^7.5.4", + "sequelize-pool": "^7.1.0", + "toposort-class": "^1.0.1", + "uuid": "^8.3.2", + "validator": "^13.9.0", + "wkx": "^0.5.0" + }, + "engines": { + "node": ">=10.0.0" + }, + "peerDependenciesMeta": { + "ibm_db": { + "optional": true + }, + "mariadb": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "oracledb": { + "optional": true + }, + "pg": { + "optional": true + }, + "pg-hstore": { + "optional": true + }, + "snowflake-sdk": { + "optional": true + }, + "sqlite3": { + "optional": true + }, + "tedious": { + "optional": true + } + } + }, + "node_modules/sequelize-pool": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/sequelize-pool/-/sequelize-pool-7.1.0.tgz", + "integrity": "sha512-G9c0qlIWQSK29pR/5U2JF5dDQeqqHRragoyahj/Nx4KOOQ3CPPfzxnfqFPCSB7x5UgjOgnZ61nSxz+fjDpRlJg==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/sequelize/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/serve-static": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", @@ -4912,6 +5932,15 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/sqlstring": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", + "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -5262,6 +6291,12 @@ "node": ">=0.6" } }, + "node_modules/toposort-class": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toposort-class/-/toposort-class-1.0.1.tgz", + "integrity": "sha512-OsLcGGbYF3rMjPUf8oKktyvCiUxSbqMMS39m33MAjLTC1DVIH6x3WSt63/M77ihI09+Sdfk1AXvfhCEeUmC7mg==", + "license": "MIT" + }, "node_modules/ts-jest": { "version": "29.4.4", "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.4.tgz", @@ -5349,6 +6384,26 @@ "license": "0BSD", "optional": true }, + "node_modules/tsx": { + "version": "4.20.6", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.6.tgz", + "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.25.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -5387,12 +6442,11 @@ } }, "node_modules/typescript": { - "version": "5.9.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", - "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5419,7 +6473,6 @@ "version": "7.12.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.12.0.tgz", "integrity": "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==", - "dev": true, "license": "MIT" }, "node_modules/unpipe": { @@ -5497,6 +6550,15 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", @@ -5512,6 +6574,15 @@ "node": ">=10.12.0" } }, + "node_modules/validator": { + "version": "13.15.15", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.15.tgz", + "integrity": "sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -5547,6 +6618,15 @@ "node": ">= 8" } }, + "node_modules/wkx": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/wkx/-/wkx-0.5.0.tgz", + "integrity": "sha512-Xng/d4Ichh8uN4l0FToV/258EjMGU9MGcA0HV2d9B/ZpZB3lqQm7nkOdZdm5GhKtLLhAE7PiVQwN4eN+2YJJUg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/wordwrap": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", diff --git a/package.json b/package.json index cbce05b..d14a3fd 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,13 @@ { "name": "codigo-abisal-server", "version": "1.0.0", - "main": "app.ts", + "type": "module", + "main": "dist/app.js", "scripts": { - "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watchAll --no-cache" + "dev": "tsx src/app.ts", + "build": "tsc", + "start": "node dist/app.js", + "test": "cross-env NODE_ENV=test node --experimental-vm-modules ./node_modules/jest/bin/jest.js --runInBand" }, "repository": { "type": "git", @@ -19,14 +23,28 @@ "description": "", "dependencies": { "bcryptjs": "^3.0.2", - "express": "^5.1.0" + "cors": "^2.8.5", + "crypto": "^1.0.1", + "dotenv": "^17.2.2", + "express": "^5.1.0", + "express-validator": "^7.2.1", + "jsonwebtoken": "^9.0.2", + "mysql2": "^3.15.1", + "sequelize": "^6.37.7" }, "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/cors": "^2.8.19", "@types/express": "^5.0.3", "@types/jest": "^30.0.0", + "@types/jsonwebtoken": "^9.0.10", + "@types/node": "^24.5.2", "@types/supertest": "^6.0.3", - "jest": "^30.1.3", + "cross-env": "^10.1.0", + "jest": "^30.2.0", "supertest": "^7.1.4", - "ts-jest": "^29.4.4" + "ts-jest": "^29.4.4", + "tsx": "^4.20.6", + "typescript": "^5.9.3" } } diff --git a/routes/.gitkeep b/routes/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..dab12bb --- /dev/null +++ b/src/app.ts @@ -0,0 +1,52 @@ +import express from "express"; +import db_connection from "./database/db_connection.js"; +import "dotenv/config"; +import "./models/UserModel.js"; +import "./models/ArticleModel.js"; +import authRouter from "./routes/authRoutes.js"; +import articleRouter from "./routes/articleRoutes.js"; +import { User } from "./models/UserModel.js"; +import { Article } from "./models/ArticleModel.js"; +import passwordResetRouter from "./routes/passwordReset.routes.js"; +import "./models/PasswordResetToken.js"; +import cors from "cors"; +import userRouter from "./routes/userRoutes.js"; // 👈 el nuevo archivo + + +User.hasMany(Article, { foreignKey: 'creator_id' }); +Article.belongsTo(User, { foreignKey: 'creator_id' }); + + export const app = express(); +const PORT = process.env.PORT ? Number(process.env.PORT) : 8000; + app.use(cors({ origin: process.env.CORS_ORIGIN || "*" })); // puerto de Vite + app.use(express.json()); + app.get("/", (_req, res) => { + res.send("Hola API"); +}); +app.get("/healthz", (_req, res) => { + res.status(200).send("ok"); +}); +app.use("/auth", authRouter ) +app.use("/article", articleRouter) +app.use("/users", userRouter); +app.use("/auth", passwordResetRouter); + +// await db_connection.sync({ alter: true }); // o { force: true } si quieres regenerar + +async function startServer() { + try { + // Sincroniza los modelos con la base de datos + await db_connection.sync(); + console.log("✅ Database synchronized successfully."); + + app.listen(PORT, () => { + console.log(`🚀 Server is running on port ${PORT}`); + }); + } catch (error) { + console.error("❌ Unable to sync database:", error); + } +} + +if (process.env.NODE_ENV !== 'test') { + startServer(); +} diff --git a/src/controllers/ArticleController.ts b/src/controllers/ArticleController.ts new file mode 100644 index 0000000..b86e23d --- /dev/null +++ b/src/controllers/ArticleController.ts @@ -0,0 +1,200 @@ +import { Article } from "../models/ArticleModel.js"; +import type { Request, Response} from "express"; +import { User } from '../models/UserModel.js'; + + +export const getAllArticles = async (_req: Request, res: Response) => { + try { + const articles = await Article.findAll(); + if (!articles || articles.length === 0) { + return res.status(404).json({ message: 'No se encontraron artículos' }); // Manejo explícito de error + } + res.status(200).json(articles); // Devuelve los artículos + } catch (error) { + console.error('Error obteniendo artículos:', error); + res.status(500).json({ message: 'Error obteniendo artículos', error }); + } +}; +export const getArticleById = async (req: Request<{ id: string }>, res: Response) => { + try { + + const { id } = req.params; + + // Buscamos por clave primaria con Sequelize (findByPk) + const article = await Article.findByPk(id); + + // Si no existe, respondemos 404 Not Found + if (!article) { + return res.status(404).json({ message: "Artículo no encontrado" }); + } + // Si existe, 200 OK con el artículo + res.status(200).json(article); + } catch (error) { + // Cualquier error inesperado -> 500 + res.status(500).json({ message: "Error obteniendo el artículo" }); + } + +} + +export const deleteArticle = async (req: Request, res: Response) => { + try { + const deleted = await Article.destroy({ where: { id: req.params.id } }); + if (deleted === 0) { + return res.status(404).json({ message: "Artículo no encontrado" }); + } + res.status(200).json({ message: "El articulo esta eliminado correctamente" }); + } catch (error) { + res.status(500).json({ message: "No se pudo eliminar el articulo" }); + } +} + +export const createArticle = async (req: Request, res: Response) => { + try { + // const user = await User.findByPk(creator_id); // Busca al usuario por su ID + if (!req.user || !req.user.userId) { + return res.status(400).json({ message: "El creador del artículo no está autenticado." }); + } + + // Aquí filtramos los campos que sí queremos guardar + const {title, description, content, category, species, image, references, } = req.body; + // Verifica si algún campo esencial falta + const creator_id = Number(req.user.userId); + + console.log("Creador ID:", creator_id); // Verifica que el creator_id sea correcto + + if (!title || !description || !content || !category || !species ) { + console.error("Faltan datos necesarios para crear el artículo"); + return res.status(400).json({ message: "Faltan datos necesarios" }); + } + + // Creamos el artículo solo con esos campos (los demás se ignoran) + + const newArticle = await Article.create({ + title, + description, + content, + category, + species, + image, + references, + creator_id +}).catch((error) => { + console.error("Error en la base de datos:", error); + throw new Error("Simulación de error en la base de datos"); + }); + + return res.status(201).json(newArticle); + } catch (error) { + console.error("Error en la base de datos:", error); + if (error instanceof Error) { + return res.status(500).json({ + message: "No se pudo crear el artículo", + error: error.message // Ahora TypeScript sabe que 'error' tiene la propiedad 'message' + }); + } else { + // Si el error no es una instancia de Error, enviamos un mensaje genérico + return res.status(500).json({ + message: "No se pudo crear el artículo", + error: "Error desconocido" + }); + } + } +}; + +//Defino el tipo del body para el update (todos opcionales) +interface UpdateArticleDTO { + title?: string; + description?: string; + content?: string; + category?: string; + species?: string; + image?: string; + references?: string; +} + +export const updateArticle = async ( + req: Request<{ id: string }, unknown, UpdateArticleDTO>, + res: Response +) => { + try { + const { id } = req.params; + const article = await Article.findByPk(id); + + if (!article) { + return res.status(404).json({ message: "Artículo no encontrado" }); + } + + // Filtramos los campos que pueden actualizarse + const { title, description, content, category, species, image, references } = req.body; + + await article.update({ title, description, content, category, species, image, references,}); + + return res.status(200).json({ + message: "Artículo actualizado correctamente", + article, + }); + } catch (_error) { + return res.status(500).json({ message: "Error actualizando el artículo" }); + } +}; + +export const likeArticle = async (req: Request, res: Response) => { + try { + const articleId = req.params.id; + const userId = req.user?.userId; + + if (!userId) { + return res.status(401).json({ message: 'No estás logueado' }); + } + + const article = await Article.findByPk(articleId); + const user = await User.findByPk(String(userId)); + + // Si el artículo o el usuario no se encontraron, nos detenemos aquí. + if (!article || !user) { + return res.status(404).json({ message: 'Artículo o usuario no encontrado' }); + } + + // --- Si llegamos hasta aquí, TypeScript ya sabe que 'article' SÍ existe --- + + await (article as any).addLikedByUsers(user); + article.likes += 1; // Ahora esta línea es segura + await article.save(); + + res.status(200).json({ message: 'Like añadido', likes: article.likes }); + + } catch (error) { + res.status(500).json({ message: 'Error en el servidor' }); + } +}; + +// Función para QUITAR like +export const unlikeArticle = async (req: Request, res: Response) => { + try { + const articleId = req.params.id; + const userId = req.user?.userId; + + if (!userId) { + return res.status(401).json({ message: 'No estás logueado' }); + } + + const article = await Article.findByPk(articleId); + const user = await User.findByPk(String(userId)); + + // Misma comprobación aquí + if (!article || !user) { + return res.status(404).json({ message: 'Artículo o usuario no encontrado' }); + } + + // --- Igual que antes, TypeScript ya sabe que 'article' existe --- + + await (article as any).removeLikedByUsers(user); + article.likes = Math.max(0, article.likes - 1); // Y esta línea también es segura + await article.save(); + + res.status(200).json({ message: 'Like eliminado', likes: article.likes }); + + } catch (error) { + res.status(500).json({ message: 'Error en el servidor' }); + } +}; diff --git a/src/controllers/AuthController.ts b/src/controllers/AuthController.ts new file mode 100644 index 0000000..d55148a --- /dev/null +++ b/src/controllers/AuthController.ts @@ -0,0 +1,151 @@ + +import { Request, Response } from "express"; +import bcrypt from "bcryptjs"; +import UserModel from "../models/UserModel.js"; +import { generateToken } from "../utils/jwt.js"; + +interface RegisterBody { + username: string; + name: string; + last_name: string; + email: string; + password: string; + role?: string; +} + +interface LoginBody { + email: string; + password: string; +} + +// 👉 Registro de usuarios +export const registerController = async ( + req: Request<{}, {}, RegisterBody>, + res: Response +): Promise => { + try { + // 1. Desestructura los campos + const { username, name, last_name, email, password, role } = req.body; + + // 2. Validación + if (!username || !name || !last_name || !email || !password) { + res.status(400).json({ message: "Todos los campos son requeridos" }); + return; + } + + // Normalizamos email + const normalizedEmail = email.toLowerCase().trim(); + + // verificar si el email ya existe + const existingUser = await UserModel.findOne({ + where: { email: normalizedEmail } + }); + + if (existingUser) { + res.status(409).json({ message: "El email ya está registrado" }); + return; + } + + // verificar si el usuario ya existe + const normalizedUserName = username.toLowerCase().trim(); + + const existingUsername = await UserModel.findOne({ + where: { username: normalizedUserName } + }); + + if (existingUsername) { + res.status(409).json({ message: "El usuario ya está registrado" }); + return; + } + + // hashear contraseña + const hashPassword = await bcrypt.hash(password, 10); + + // 3. Crear usuario + const newUser = await UserModel.create({ + username, + name, + last_name, + email: normalizedEmail, + password: hashPassword, + role: role || "user", // Por defecto "user" + }); + + // 4. Generar token JWT + const token = generateToken({ + userId: BigInt((newUser as any).id), // forzamos bigint desde id (que suele ser number en Sequelize) + email: newUser.email, + role: newUser.role, + }); + + res.status(201).json({ + message: "Usuario registrado exitosamente", + token, + // user: { + // id: newUser.id.toString(), + // username: newUser.username, + // email: newUser.email, + // name: newUser.name, + // last_name: newUser.last_name, + // role: newUser.role, + // }, + }); + } catch (error) { + res.status(500).json({ + message: error instanceof Error ? error.message : "Unexpected error", + }); + } +}; + +// 👉 Login de usuarios +export const loginController = async ( + req: Request<{}, {}, LoginBody>, + res: Response +): Promise => { + try { + const { email, password } = req.body; + + if (!email || !password) { + res.status(400).json({ message: "Email y password son requeridos" }); + return; + } + + // Normalizamos email + const normalizedEmail = email.toLowerCase().trim(); + + // buscar usuario por email normalizado + const user = await UserModel.findOne({ + where: { email: normalizedEmail } + }); + +if (!user || !(await bcrypt.compare(password, user.password))) { + // Devolvemos 401 con un mensaje genérico + res.status(401).json({ message: "Email o contraseña incorrectos" }); + return; + } + + // Generar token JWT + const token = generateToken({ + userId: BigInt((user as any).id), // forzamos bigint para que cumpla con TokenPayload.userId + email: user.email, + role: user.role, + }); + + res.status(200).json({ + message: "Login exitoso", + token, + // user: { + // id: user.id.toString(), + // username: user.username, + // email: user.email, + // name: user.name, + // last_name: user.last_name, + // role: user.role, + // }, + }); + } catch (error) { + res.status(500).json({ + message: error instanceof Error ? error.message : "Unexpected error", + }); + } +}; \ No newline at end of file diff --git a/src/controllers/PasswordResetController.ts b/src/controllers/PasswordResetController.ts new file mode 100644 index 0000000..d6a5b48 --- /dev/null +++ b/src/controllers/PasswordResetController.ts @@ -0,0 +1,205 @@ +// import { Request, Response } from "express"; +// import bcrypt from "bcryptjs"; +// import User from "../models/UserModel.js"; +// import PasswordResetToken from "../models/PasswordResetToken.js"; +// import { generateRawToken, hashToken } from "../utils/resetToken.js"; + +// // Enviar email (usa nodemailer en prod; aquí vamos a loguear el link en dev) +// async function sendResetEmail(to: string, url: string) { +// if (process.env.NODE_ENV === "production") { +// // TODO: integra nodemailer o tu proveedor (Sendgrid, SES, etc.) +// } else { +// console.log("🔗 Reset link (dev):", url); +// } +// } + +// export const forgotPassword = async (req: Request, res: Response): Promise => { +// const normalizedEmail = String(req.body.email).toLowerCase().trim(); + +// // Respuesta genérica SIEMPRE (no revelar si existe) +// const generic = { message: "Si el email existe, te enviaremos instrucciones para restablecer tu contraseña." }; + +// // Busca usuario +// const user = await User.findOne({ where: { email: normalizedEmail } }); +// if (!user) { +// res.status(200).json(generic); +// return; +// } + +// // Genera token +// const rawToken = generateRawToken(); +// const tokenHash = hashToken(rawToken); +// const expires = new Date(Date.now() + 15 * 60 * 1000); // 15 min + +// // Opcional: invalida tokens previos no usados +// await PasswordResetToken.update( +// { used_at: new Date() }, +// { where: { user_id: user.id, used_at: null } } +// ); + +// // Guarda token +// await PasswordResetToken.create({ +// user_id: user.id, // token_hash: tokenHash, +// expires_at: expires, +// }); + + +// // Construye URL de reset +// const resetUrl = `${process.env.FRONTEND_URL ?? "http://localhost:5173"}/reset-password?token=${rawToken}`; + +// // Manda email (o log en dev) +// await sendResetEmail(user.email, resetUrl); + +// res.status(200).json(generic); +// }; + +// export const resetPassword = async (req: Request, res: Response): Promise => { +// const { token, newPassword } = req.body as { token: string; newPassword: string }; + +// const tokenHash = hashToken(token); +// const now = new Date(); + +// // Busca token válido +// const prt = await PasswordResetToken.findOne({ +// where: { +// token_hash: tokenHash, +// used_at: null, +// }, +// }); + +// if (!prt || prt.expires_at < now) { +// res.status(400).json({ message: "Token inválido o expirado" }); +// return; +// } + +// // Busca usuario +// const user = await User.findByPk(prt.user_id); +// if (!user) { +// res.status(400).json({ message: "Token inválido" }); +// return; +// } + +// // Actualiza contraseña +// const hash = await bcrypt.hash(newPassword, 10); +// user.password = hash; +// await user.save(); + +// // Marca token como usado +// prt.used_at = new Date(); +// await prt.save(); + +// res.status(200).json({ message: "Contraseña restablecida correctamente. Ya puedes iniciar sesión." }); +// }; +import { Request, Response } from "express"; +import bcrypt from "bcryptjs"; +import User from "../models/UserModel.js"; +import PasswordResetToken from "../models/PasswordResetToken.js"; +import { generateRawToken, hashToken } from "../utils/resetToken.js"; + + // Tipo local en snake_case para trabajar con objetos planos de Sequelize + type PasswordResetTokenSnake = { + id: number | string | bigint; + token_hash: string; + user_id: number | string | bigint; + expires_at: string | Date; + used_at: string | Date | null; +}; + +// Enviar email (usa nodemailer en prod; aquí vamos a loguear el link en dev) +async function sendResetEmail(to: string, url: string) { + if (process.env.NODE_ENV === "production") { + // TODO: integra nodemailer o tu proveedor (Sendgrid, SES, etc.) + } else { + console.log("🔗 Reset link (dev):", url, "➡️ to:", to); + } +} + +export const forgotPassword = async (req: Request, res: Response): Promise => { + const normalizedEmail = String(req.body.email).toLowerCase().trim(); + + // Respuesta genérica SIEMPRE (no revelar si existe) + const generic = { message: "Si el email existe, te enviaremos instrucciones para restablecer tu contraseña." }; + + // Busca usuario + const user = await User.findOne({ where: { email: normalizedEmail } }); + if (!user) { + res.status(200).json(generic); + return; + } + + // Genera token + const rawToken = generateRawToken(); + const tokenHash = hashToken(rawToken); + const expires = new Date(Date.now() + 15 * 60 * 1000); // 15 min + + // Opcional: invalida tokens previos no usados + await PasswordResetToken.update( + { used_at: new Date() }, + { where: { user_id: (user as any).id, used_at: null } } // forzamos tipo laxo por si id es bigint + ); + + // Guarda token + await PasswordResetToken.create({ + user_id: (user as any).id, // evitar choque bigint/number + token_hash: tokenHash, + expires_at: expires, + }); + + // Construye URL de reset + const resetUrl = `${process.env.FRONTEND_URL ?? "http://localhost:5174"}/reset-password?token=${rawToken}`; + + // Manda email (o log en dev) + await sendResetEmail((user as any).email, resetUrl); // tipado laxo por consistencia + + res.status(200).json(generic); +}; + +export const resetPassword = async (req: Request, res: Response): Promise => { + const { token, newPassword } = req.body as { token: string; newPassword: string }; + + const tokenHash = hashToken(token); + const now = new Date(); + + // Busca token válido + const prtRow = await PasswordResetToken.findOne({ + where: { + token_hash: tokenHash, + used_at: null, + }, + }); + + // Si no existe, error inmediato + if (!prtRow) { + res.status(400).json({ message: "Token inválido o expirado" }); + return; + } + + // Convertimos a objeto plano y lo tipamos en snake_case + const t = ((prtRow as any).toJSON ? (prtRow as any).toJSON() : prtRow) as PasswordResetTokenSnake; + + // Aseguramos comparación con Date aunque venga string + if (new Date(t.expires_at) < now) { + res.status(400).json({ message: "Token inválido o expirado" }); + return; + } + + // Busca usuario por PK usando el user_id del token + const userByToken = await User.findByPk(t.user_id as any); + if (!userByToken) { + res.status(400).json({ message: "Token inválido" }); + return; + } + + // Actualiza contraseña + const hash = await bcrypt.hash(newPassword, 10); + (userByToken as any).password = hash; + await userByToken.save(); + + // Marca token como usado (update por WHERE, más robusto) + await PasswordResetToken.update( + { used_at: new Date() }, + { where: { token_hash: tokenHash } } + ); + + res.status(200).json({ message: "Contraseña restablecida correctamente. Ya puedes iniciar sesión." }); +}; diff --git a/src/controllers/UserController.ts b/src/controllers/UserController.ts new file mode 100644 index 0000000..896e4e1 --- /dev/null +++ b/src/controllers/UserController.ts @@ -0,0 +1,191 @@ +import { Request, Response } from "express"; +import UserModel from "../models/UserModel.js"; + +/** + * 🎓 EXPLICACIÓN: UserController + * + * Controlador simple para gestión de usuarios por parte del admin. + * Sigue la misma estructura que ArticleController y AuthController. + * + * IMPORTANTE: Estos endpoints requieren middleware isAdmin + */ + +// ======================================== +// 📋 GET /users - Obtener todos los usuarios +// ======================================== +export const getAllUsers = async (_req: Request, res: Response): Promise => { + try { + // Traemos todos los usuarios pero SIN el campo password + const users = await UserModel.findAll({ + attributes: { exclude: ['password'] } // ✅ Excluye password del resultado + }); + + if (!users || users.length === 0) { + res.status(404).json({ message: 'No se encontraron usuarios' }); + return; + } + + res.status(200).json(users); + } catch (error) { + console.error('Error obteniendo usuarios:', error); + res.status(500).json({ + message: 'Error obteniendo usuarios', + error + }); + } +}; + +// ======================================== +// 🗑️ DELETE /user/:id - Eliminar usuario +// ======================================== +export const deleteUser = async (req: Request, res: Response): Promise => { + try { + const { id } = req.params; + + // ✅ VALIDACIÓN 1: Prevenir auto-eliminación + // req.user viene del middleware verifyToken + if (req.user && Number(req.user.userId) === Number(id)) { // (Convierte ambos a number) + res.status(403).json({ + message: "No puedes eliminar tu propia cuenta" + }); + return; + } + + // ✅ VALIDACIÓN 2: Verificar que el usuario existe + const user = await UserModel.findByPk(id); + + if (!user) { + res.status(404).json({ message: "Usuario no encontrado" }); + return; + } + + // ✅ Eliminar el usuario + const deleted = await UserModel.destroy({ + where: { id } + }); + + if (deleted === 0) { + res.status(404).json({ message: "No se pudo eliminar el usuario" }); + return; + } + + res.status(200).json({ + message: "Usuario eliminado correctamente", + deletedUserId: Number(id), + deletedUsername: user.username + }); + } catch (error) { + console.error('Error eliminando usuario:', error); + res.status(500).json({ + message: "No se pudo eliminar el usuario", + error + }); + } +}; + +// ======================================== +// ✏️ PUT /user/:id - Actualizar usuario +// ======================================== + +// Tipo para los campos que pueden actualizarse +interface UpdateUserDTO { + username?: string; + name?: string; + last_name?: string; + email?: string; + role?: string; +} + +export const updateUser = async ( + req: Request<{ id: string }, unknown, UpdateUserDTO>, + res: Response +): Promise => { + try { + const { id } = req.params; + + // ✅ VALIDACIÓN 1: Verificar que no se intente actualizar password + if ('password' in req.body) { + res.status(400).json({ + message: 'No puedes actualizar la contraseña desde este endpoint. Usa /auth/reset-password' + }); + return; + } + + // ✅ VALIDACIÓN 2: Verificar que el usuario existe + const user = await UserModel.findByPk(id); + + if (!user) { + res.status(404).json({ message: "Usuario no encontrado" }); + return; + } + + // ✅ VALIDACIÓN 3: Si se cambia el rol, verificar que sea válido + const { role, email, username, name, last_name } = req.body; + + if (role && !['user', 'admin'].includes(role)) { + res.status(400).json({ + message: 'El rol debe ser "user" o "admin"' + }); + return; + } + + // ✅ VALIDACIÓN 4: Si se cambia el email, verificar que no exista + if (email) { + const normalizedEmail = email.toLowerCase().trim(); + + const existingUser = await UserModel.findOne({ + where: { email: normalizedEmail } + }); + + // Verificar que el email no pertenezca a otro usuario + if (existingUser && existingUser.id !== user.id) { + res.status(409).json({ + message: "Este email ya está en uso por otro usuario" + }); + return; + } + } + + // ✅ VALIDACIÓN 5: Si se cambia el username, verificar que no exista + if (username) { + const normalizedUsername = username.toLowerCase().trim(); + + const existingUsername = await UserModel.findOne({ + where: { username: normalizedUsername } + }); + + // Verificar que el username no pertenezca a otro usuario + if (existingUsername && existingUsername.id !== user.id) { + res.status(409).json({ + message: "Este nombre de usuario ya está en uso" + }); + return; + } + } + + // ✅ Actualizar el usuario (solo los campos enviados) + await user.update({ + username, + name, + last_name, + email: email ? email.toLowerCase().trim() : undefined, + role + }); + + // ✅ Devolver el usuario actualizado sin password + const updatedUser = await UserModel.findByPk(id, { + attributes: { exclude: ['password'] } + }); + + res.status(200).json({ + message: "Usuario actualizado correctamente", + user: updatedUser, + }); + } catch (error) { + console.error('Error actualizando usuario:', error); + res.status(500).json({ + message: "Error actualizando el usuario", + error + }); + } +}; diff --git a/src/database/db_connection.ts b/src/database/db_connection.ts new file mode 100644 index 0000000..f6dd45f --- /dev/null +++ b/src/database/db_connection.ts @@ -0,0 +1,80 @@ +// import { Sequelize } from "sequelize"; +// import dotenv from "dotenv"; + +// // 1) Cargar .env.test si estamos en test; si no, .env normal +// dotenv.config({ path: process.env.NODE_ENV === "test" ? ".env.test" : ".env" }); + +// // 2) Bandera simple para saber si estamos en test +// const isTest = process.env.NODE_ENV === "test"; + +// // 3) Seguridad: en test, exige que la BD termine en _test +// if (isTest && process.env.DB_NAME && !process.env.DB_NAME.endsWith("_test")) { +// throw new Error(`En test, DB_NAME debe terminar en "_test". Actual: ${process.env.DB_NAME}`); +// } + +// // 4) Conexión +// const db_connection = new Sequelize( +// process.env.DB_NAME as string, +// process.env.DB_USER as string, +// process.env.DB_PASS as string, +// { +// host: process.env.DB_HOST || "localhost", +// dialect: "mysql", +// logging: isTest ? false : console.log, +// define: { timestamps: false }, +// } +// ); + +// export default db_connection; + +// src/database/db_connection.ts +import { Sequelize } from "sequelize"; +import fs from "fs"; +import dotenv from "dotenv"; + +// (1) Cargar .env.test si estamos en test; si no, .env normal +dotenv.config({ path: process.env.NODE_ENV === "test" ? ".env.test" : ".env" }); + +// (2) Saber si estamos en test +const isTest = process.env.NODE_ENV === "test"; + +// (3) En test, obligar a que la BD termine en _test (seguridad) +if (isTest && process.env.DB_NAME && !process.env.DB_NAME.endsWith("_test")) { + throw new Error(`En test, DB_NAME debe terminar en "_test". Actual: ${process.env.DB_NAME}`); +} + +// (4) TLS opcional activado por env +const sslEnabled = String(process.env.DB_SSL || "").toLowerCase() === "true"; + +// (5) Preparar opciones de SSL para Sequelize +let dialectOptions: any = {}; +if (sslEnabled) { + const caPath = process.env.DB_SSL_CA_PATH; // p.ej. /app/certs/tidb-ca.pem en Docker/Render + const hasFile = caPath && fs.existsSync(caPath); + const caFromFile = hasFile ? fs.readFileSync(caPath, "utf8") : undefined; + + const caFromEnv = process.env.DB_SSL_CA_PEM; // alternativa: el PEM entero en una ENV + + const ca = caFromEnv || caFromFile; // preferimos ENV si está; si no, archivo + + dialectOptions = ca + ? { ssl: { ca, minVersion: "TLSv1.2", rejectUnauthorized: true } } + : { ssl: { minVersion: "TLSv1.2", rejectUnauthorized: true } }; +} + +// (6) Conexión Sequelize +const db_connection = new Sequelize( + process.env.DB_NAME as string, + process.env.DB_USER as string, + process.env.DB_PASS as string, + { + host: process.env.DB_HOST || "localhost", + port: Number(process.env.DB_PORT) || 3306, // TiDB: 4000 + dialect: "mysql", + dialectOptions, // activa TLS si DB_SSL=true + logging: isTest ? false : console.log, + define: { timestamps: false }, + } +); + +export default db_connection; diff --git a/src/interface/articleInterface.ts b/src/interface/articleInterface.ts new file mode 100644 index 0000000..9aaec2c --- /dev/null +++ b/src/interface/articleInterface.ts @@ -0,0 +1,14 @@ +export interface ArticleAttributes { + id: number; + creator_id: number; //TENER EN CUENTA QUE DEBE VENIR DEL MODELO DE USER + title: string; + description: string; + content:string; + category: string; + species: string; + image?: string; + references?: string; + likes: number; + created_at: Date; + updated_at: Date; +} \ No newline at end of file diff --git a/src/interface/userInterface.ts b/src/interface/userInterface.ts new file mode 100644 index 0000000..4502bba --- /dev/null +++ b/src/interface/userInterface.ts @@ -0,0 +1,11 @@ +export interface UserAttributes { + id: number; + username: string; + email: string; + password: string; + name: string; + last_name: string; + role: string; + created_at: Date; + updated_at: Date; +} \ No newline at end of file diff --git a/src/middlewares/articleMiddlewares.ts b/src/middlewares/articleMiddlewares.ts new file mode 100644 index 0000000..e5a517e --- /dev/null +++ b/src/middlewares/articleMiddlewares.ts @@ -0,0 +1,26 @@ +// 1) Importo lo necesario de express y express-validator +// import { validationResult } from "express-validator"; +// import type { Request, Response, NextFunction } from "express"; + +// // 2) Middleware que revisa si hubo errores de validación +// export function checkValidations(req: Request, res: Response, next: NextFunction) { +// // 3) Recoge el resultado de todos los body()/param()/query() anteriores +// const errors = validationResult(req); + +// // 4) Si hay errores, respondemos 400 con un listado claro +// if (!errors.isEmpty()) { +// return res.status(400).json({ +// message: "Error de validación", +// // 5) Solo mostramos el primer error por campo (más limpio para el front) +// errors: errors.array({ onlyFirstError: true }).map((e: any) => ({ +// field: e.param, // ← NOMBRE DEL CAMPO (p.ej., "title", "id") +// message: e.msg, // ← MENSAJE que pusiste con .withMessage(...) +// location: e.location, // ← DONDE falló: "body" | "params" | "query" +// })), +// }); +// } + +// // 6) Si no hay errores, seguimos al siguiente middleware/controlador +// next(); +// } + diff --git a/src/middlewares/authMiddlewares.ts b/src/middlewares/authMiddlewares.ts new file mode 100644 index 0000000..1643868 --- /dev/null +++ b/src/middlewares/authMiddlewares.ts @@ -0,0 +1,102 @@ +import { Request, Response, NextFunction } from "express"; +import { verifyToken, TokenPayload } from "../utils/jwt.js"; +import { validationResult } from "express-validator"; + +// Extender el tipo Request para incluir user +declare global { + namespace Express { + interface Request { + user?: TokenPayload; + } + } +} + +/** + * Middleware que verifica si el usuario está autenticado + */ +export const authMiddleware = ( + req: Request, + res: Response, + next: NextFunction +): void => { + try { + // Obtener el token del header Authorization + const authHeader = req.headers.authorization; + + if (!authHeader) { + res.status(401).json({ message: "No se proporcionó token de autenticación" }); + return; + } + + // El formato esperado es: "Bearer " + const token = authHeader.split(" ")[1]; + + if (!token) { + res.status(401).json({ message: "Formato de token inválido" }); + return; + } + + // Verificar el token + const decoded = verifyToken(token); + + if (!decoded) { + res.status(401).json({ message: "Token inválido o expirado" }); + return; + } + + // Adjuntar la información del usuario al request + req.user = decoded; + + // Continuar con el siguiente middleware o ruta + next(); + } catch (error) { + res.status(500).json({ message: "Error en la autenticación" }); + } +}; + +/** + * Middleware que verifica si el usuario tiene un rol específico + */ +export const requireRole = (allowedRoles: Array) => { + return (req: Request, res: Response, next: NextFunction): void => { + if (!req.user) { + res.status(401).json({ message: "Usuario no autorizado" }); + return; + } + + if (!allowedRoles.includes(req.user.role)) { + res.status(403).json({ + message: "No tienes permisos para acceder a este recurso" + }); + return; + } + + next(); + }; +}; + + +export function handleValidation(req: Request, res: Response, next: NextFunction) { + const result = validationResult(req); + + if (!result.isEmpty()) { + const flatErrors = result.array({ onlyFirstError: true }).flatMap((err: any) => { + // Si es AlternativeValidationError (oneOf), aplanamos sus nestedErrors + if (err?.nestedErrors && Array.isArray(err.nestedErrors)) { + return err.nestedErrors.map((ne: any) => ({ + field: ne.path ?? ne.param ?? "unknown", + msg: ne.msg, + })); + } + // ValidationError normal + return [{ field: err.path ?? err.param ?? "unknown", msg: err.msg }]; + }); + + return res.status(422).json({ + message: "Faltan datos necesarios", + errors: flatErrors, + }); + } + + next(); +} diff --git a/src/middlewares/handleValidation.ts b/src/middlewares/handleValidation.ts new file mode 100644 index 0000000..c9ef378 --- /dev/null +++ b/src/middlewares/handleValidation.ts @@ -0,0 +1,24 @@ +import { Request, Response, NextFunction } from "express"; +import { validationResult } from "express-validator"; + +/** + * Middleware que revisa si hay errores de validación y los devuelve en formato 422. + */ +export function handleValidation(req: Request, res: Response, next: NextFunction) { + const result = validationResult(req); + + if (!result.isEmpty()) { + // En express-validator v7 la propiedad es "path" (antes era "param") + const errors = result.array({ onlyFirstError: true }).map((err: any) => ({ + field: err.path ?? err.param ?? "unknown", + msg: err.msg, + })); + + return res.status(422).json({ + message: "Errores de validación", + errors, + }); + } + + next(); +} diff --git a/src/models/ArticleModel.ts b/src/models/ArticleModel.ts new file mode 100644 index 0000000..65d2b9d --- /dev/null +++ b/src/models/ArticleModel.ts @@ -0,0 +1,149 @@ +// 1) Importo tipos y utilidades de Sequelize +import { DataTypes, Model, Optional } from "sequelize"; +// 2) Importo mi conexión a la base de datos (tu Sequelize ya configurado) +import db_connection from "../database/db_connection.js"; +import { ArticleAttributes } from "../interface/articleInterface.js"; +import { User } from './UserModel.js'; + + +// 4) Campos opcionales AL CREAR (Sequelize los rellena solo) +export type ArticleCreationAttributes = Optional< + ArticleAttributes, + "id" | "created_at" | "updated_at" | "image" | "references" | "likes" +>; + +// 5) Defino la clase del modelo (tipada) +export class Article + extends Model + implements ArticleAttributes +{ + declare id: number; // Cambié de 'bigint' a 'number' (equivalente a 'integer' en JS) + declare creator_id: number; // Cambié de 'bigint' a 'number' (equivalente a 'integer' en JS) + declare title: string; + declare description: string; + declare content: string; + declare category: string; + declare species: string; + declare image: string; + declare references: string; + declare created_at: Date; + declare updated_at: Date; + declare likes: number; +} + +// 6) Inicializo (equivalente a define) y mapeo columnas/validaciones +Article.init( + { + id: { + type: DataTypes.INTEGER, // Cambié de DataTypes.BIGINT a DataTypes.INTEGER + autoIncrement: true, + primaryKey: true, + }, + title: { + type: DataTypes.STRING, + allowNull: false, + validate: { + notNull: { msg: "titulo no puede estar vacío" }, + len: { args: [3, 255], msg: "titulo mínimo 3 caracteres" }, + }, + }, + description: { + type: DataTypes.STRING, + allowNull: false, + validate: { + notNull: { msg: "descripcion no puede estar vacío" }, + len: { args: [3, 255], msg: "descripcion mínimo 3 caracteres" }, + }, + }, + content: { + type: DataTypes.TEXT("long"), + allowNull: false, + validate: { + notNull: { msg: "content no puede estar vacío" }, + len: { args: [6, 65535], msg: "content mínimo 6 caracteres" }, + }, + }, + category: { + type: DataTypes.ENUM('Fauna Abisal', 'Ecosistemas', 'Exploración', 'Conservación'), + allowNull: false, + validate: { + notNull: { msg: "category no puede estar vacío" }, + isIn: { + args: [['Fauna Abisal', 'Ecosistemas', 'Exploración', 'Conservación']], // Esta es la validación isIn + msg: "category debe ser uno de los siguientes: 'Fauna Abisal', 'Ecosistemas', 'Exploración', 'Conservación'" + } + }, +}, + species: { + type: DataTypes.STRING, + allowNull: false, + validate: { + notNull: { msg: "species no puede estar vacío" }, + len: { args: [6, 255], msg: "species mínimo 6 caracteres" }, + }, + }, + image: { + type: DataTypes.STRING, + allowNull: true, + validate: { + len: { args: [6, 255], msg: "image mínimo 6 caracteres" }, + }, + }, + references: { + type: DataTypes.STRING, + allowNull: true, + validate: { + len: { args: [6, 255], msg: "references mínimo 6 caracteres" }, + }, + }, + creator_id: { + type: DataTypes.INTEGER, // Cambié de DataTypes.BIGINT a DataTypes.INTEGER + allowNull: false, + references: { + model: 'users', + key: 'id', + }, + }, + created_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + updated_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + likes: { // <-- AÑADIR ESTE CAMPO + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 0, + }, + }, + { + sequelize: db_connection, // ← tu conexión + tableName: "articles", // ← nombre real de la tabla + timestamps: true, // ← activa created/updated + underscored: true, // ← columnas snake_case + createdAt: "created_at", // ← mapea el nombre + updatedAt: "updated_at", // ← mapea el nombre + } +); + +export const UserLikes = db_connection.define('user_likes', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, +}, { + timestamps: false, + tableName: 'user_likes', +}); + +User.belongsToMany(Article, { through: UserLikes, as: 'likedArticles' }); +Article.belongsToMany(User, { through: UserLikes, as: 'likedByUsers' }); + + +// 7) ¡Exporto el modelo! (puedes default o nombrado) +export default Article; diff --git a/src/models/PasswordResetToken.ts b/src/models/PasswordResetToken.ts new file mode 100644 index 0000000..f9211a4 --- /dev/null +++ b/src/models/PasswordResetToken.ts @@ -0,0 +1,41 @@ +import { DataTypes, Model, Optional } from "sequelize"; +import db from "../database/db_connection.js"; + +interface PasswordResetTokenAttrs { + id: number; + user_id: bigint; // FK a users.id + token_hash: string; // SHA-256 del token + expires_at: Date; + used_at: Date | null; + created_at: Date; + updated_at: Date; +} + +type Creation = Optional< + PasswordResetTokenAttrs, + "id" | "used_at" | "created_at" | "updated_at" +>; + +export class PasswordResetToken extends Model {} + +PasswordResetToken.init( + { + id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true }, + user_id: { type: DataTypes.BIGINT, allowNull: false }, + token_hash: { type: DataTypes.STRING, allowNull: false }, + expires_at: { type: DataTypes.DATE, allowNull: false }, + used_at: { type: DataTypes.DATE, allowNull: true }, + created_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, + updated_at: { type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW }, + }, + { + sequelize: db, + tableName: "password_reset_tokens", + underscored: true, + timestamps: true, + createdAt: "created_at", + updatedAt: "updated_at", + } +); + +export default PasswordResetToken; diff --git a/src/models/UserModel.ts b/src/models/UserModel.ts new file mode 100644 index 0000000..7198df5 --- /dev/null +++ b/src/models/UserModel.ts @@ -0,0 +1,101 @@ +// 1) Importo tipos y utilidades de Sequelize +import { DataTypes, Model, Optional } from "sequelize"; +// 2) Importo mi conexión a la base de datos (tu Sequelize ya configurado) +import db_connection from "../database/db_connection.js" +import { UserAttributes } from "../interface/userInterface.js"; + +// 3) Declaro cómo es un usuario en la BD (TODOS los campos) + + +// 4) Campos opcionales AL CREAR (Sequelize los rellena solo) +export type UserCreationAttributes = Optional< + UserAttributes, + "id" | "created_at" | "updated_at" +>; + +// 5) Defino la clase del modelo (tipada) +export class User + extends Model + implements UserAttributes +{ + declare id: number; + declare username: string; + declare email: string; + declare password: string; + declare name: string; + declare last_name: string; + declare role: string; + declare created_at: Date; + declare updated_at: Date; +} + +// 6) Inicializo (equivalente a define) y mapeo columnas/validaciones +User.init( + { + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + username: { + type: DataTypes.STRING, + allowNull: false, + validate: { + notNull: { msg: "username no puede estar vacío" }, + len: { args: [2, 255], msg: "username mínimo 2 caracteres" }, + }, + }, + email: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + validate: { + notNull: { msg: "email no puede estar vacío" }, + isEmail: { msg: "email no es válido" }, + }, + }, + password: { + type: DataTypes.STRING, + allowNull: false, + validate: { + notNull: { msg: "password no puede estar vacío" }, + len: { args: [6, 255], msg: "password mínimo 6 caracteres" }, + }, + }, + name: { + type: DataTypes.STRING, + allowNull: false, + }, + last_name: { + type: DataTypes.STRING, + allowNull: false, + }, + role: { + type: DataTypes.STRING, + allowNull: false, + defaultValue: "user", + }, + created_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + updated_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + }, + { + sequelize: db_connection, // ← tu conexión + tableName: "users", // ← nombre real de la tabla + timestamps: true, // ← activa created/updated + underscored: true, // ← columnas snake_case + createdAt: "created_at", // ← mapea el nombre + updatedAt: "updated_at", // ← mapea el nombre + } +); + +// 7) ¡Exporto el modelo! (puedes default o nombrado) +export default User; + diff --git a/src/routes/articleRoutes.ts b/src/routes/articleRoutes.ts new file mode 100644 index 0000000..739b9b6 --- /dev/null +++ b/src/routes/articleRoutes.ts @@ -0,0 +1,26 @@ +import express from 'express'; +import {getAllArticles,getArticleById, deleteArticle,createArticle,updateArticle, likeArticle, unlikeArticle } from '../controllers/ArticleController.js'; +import { authMiddleware, requireRole, handleValidation} from '../middlewares/authMiddlewares.js'; +import { createArticleValidators, updateArticleValidators, idParamValidators } from '../validators/articleValidators.js'; +// import { checkValidations } from "../middlewares/articleMiddlewares.js"; + + + +const articleRouter = express.Router(); + +// Rutas públicas (sin autenticación) +articleRouter.get("/", getAllArticles); +articleRouter.get("/:id", idParamValidators, getArticleById,); + +// Rutas protegidas (handleValidation, createArticle, createArticleValidators, checkValidations); +articleRouter.post("/", authMiddleware, requireRole(["admin", "user"]), createArticleValidators, handleValidation, createArticle,); +articleRouter.put("/:id", authMiddleware, requireRole(["admin"]), idParamValidators, updateArticleValidators, handleValidation, updateArticle,); +articleRouter.delete("/:id",authMiddleware, requireRole(["admin"]), idParamValidators, handleValidation, deleteArticle); +// Dar like +articleRouter.post("/:id/like", authMiddleware, idParamValidators, handleValidation, likeArticle); + +// Quitar like +articleRouter.delete("/:id/like", authMiddleware, idParamValidators, handleValidation, unlikeArticle); + + +export default articleRouter; \ No newline at end of file diff --git a/src/routes/authRoutes.ts b/src/routes/authRoutes.ts new file mode 100644 index 0000000..ea9747e --- /dev/null +++ b/src/routes/authRoutes.ts @@ -0,0 +1,24 @@ +import express from "express"; +import { registerController, loginController } from "../controllers/AuthController.js"; +import { registerValidator, loginValidator } from "../validators/userValidators.js"; +import { handleValidation } from "../middlewares/authMiddlewares.js"; + +const authRouter = express.Router(); + +// Registro con validación +authRouter.post( + "/register", + registerValidator, + handleValidation, + registerController +); + +// Login con validación +authRouter.post( + "/login", + loginValidator, + handleValidation, + loginController +); + +export default authRouter; diff --git a/src/routes/passwordReset.routes.ts b/src/routes/passwordReset.routes.ts new file mode 100644 index 0000000..4270805 --- /dev/null +++ b/src/routes/passwordReset.routes.ts @@ -0,0 +1,11 @@ +import { Router } from "express"; +import { forgotPassword, resetPassword } from "../controllers/PasswordResetController.js"; +import { forgotValidator, resetValidator } from "../validators/passwordResetValidators.js"; +import { handleValidation } from "../middlewares/handleValidation.js"; + +const router = Router(); + +router.post("/forgot-password", forgotValidator, handleValidation, forgotPassword); +router.post("/reset-password", resetValidator, handleValidation, resetPassword); + +export default router; diff --git a/src/routes/userRoutes.ts b/src/routes/userRoutes.ts new file mode 100644 index 0000000..3224a47 --- /dev/null +++ b/src/routes/userRoutes.ts @@ -0,0 +1,49 @@ +import { Router, Request, Response } from "express"; +import User from "../models/UserModel.js" +import {getAllUsers, deleteUser, updateUser} from "../controllers/UserController.js"; +import { authMiddleware, requireRole } from "../middlewares/authMiddlewares.js"; + +const router = Router(); + +/** + * GET /user/:id + * Devuelve datos públicos del usuario (id, username, name) + */ +router.get("/user/:id", async (req: Request, res: Response) => { + try { + const id = Number(req.params.id); + + // 1️⃣ Validamos que el ID sea un número válido + if (Number.isNaN(id) || id < 1) { + return res.status(400).json({ message: "id inválido" }); + } + + // 2️⃣ Buscamos al usuario en la base de datos + const user = await User.findByPk(id, { + attributes: ["id", "username", "name"], // 👈 solo devolvemos datos públicos + }); + + // 3️⃣ Si no existe, devolvemos 404 + if (!user) { + return res.status(404).json({ message: "Usuario no encontrado" }); + } + + // 4️⃣ Si todo va bien, devolvemos el usuario en formato JSON + return res.json(user); + } catch (error) { + console.error("Error en GET /user/:id:", error); + return res.status(500).json({ message: "Error al obtener usuario" }); + } +}); + + +// 📋 GET /users - Obtener todos los usuarios +router.get("/", authMiddleware, requireRole(["admin"]), getAllUsers); + +// 🗑️ DELETE /user/:id - Eliminar usuario +router.delete("/:id", authMiddleware, requireRole(["admin"]), deleteUser); + +// ✏️ PUT /user/:id - Actualizar usuario +router.put("/:id", authMiddleware, requireRole(["admin"]), updateUser); + +export default router; diff --git a/src/utils/jwt.ts b/src/utils/jwt.ts new file mode 100644 index 0000000..60f98e5 --- /dev/null +++ b/src/utils/jwt.ts @@ -0,0 +1,55 @@ + import jwt from "jsonwebtoken"; + + +// const JWT_SECRET = process.env.JWT_SECRET || "default_secret_change_in_production"; +// const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "7d"; +const JWT_SECRET = (process.env.JWT_SECRET ?? "default_secret_change_in_production"); +const JWT_EXPIRES_IN = (process.env.JWT_EXPIRES_IN ?? "7d"); + +export interface TokenPayload { + userId: bigint; + email: string; + role: string; +} + +/** + * Genera un token JWT + */ +export const generateToken = (payload: TokenPayload): string => { + // Convertir bigint a string para JWT + const sanitizedPayload = { + userId: payload.userId.toString(), + email: payload.email, + role: payload.role, + }; + // const options: SignOptions = { expiresIn: JWT_EXPIRES_IN }; + + + // return jwt.sign(sanitizedPayload, JWT_SECRET, { + // expiresIn: JWT_EXPIRES_IN, + const options: jwt.SignOptions = { + expiresIn: JWT_EXPIRES_IN as unknown as jwt.SignOptions["expiresIn"], + }; + + return jwt.sign(sanitizedPayload, JWT_SECRET as jwt.Secret, options); +}; + + + +/** + * Verifica y decodifica un token JWT + */ +export const verifyToken = (token: string): TokenPayload | null => { + try { + const decoded = jwt.verify(token, JWT_SECRET) as any; + + // Convertir userId de string a bigint + return { + userId: BigInt(decoded.userId), + email: decoded.email, + role: decoded.role, + }; + } catch (error) { + return null; + } +}; \ No newline at end of file diff --git a/src/utils/resetToken.ts b/src/utils/resetToken.ts new file mode 100644 index 0000000..cdccd7b --- /dev/null +++ b/src/utils/resetToken.ts @@ -0,0 +1,9 @@ +import crypto from "crypto"; + +export function generateRawToken(): string { + return crypto.randomBytes(32).toString("hex"); // 64 chars +} + +export function hashToken(token: string): string { + return crypto.createHash("sha256").update(token).digest("hex"); +} diff --git a/src/validators/articleValidators.ts b/src/validators/articleValidators.ts new file mode 100644 index 0000000..4ef3814 --- /dev/null +++ b/src/validators/articleValidators.ts @@ -0,0 +1,108 @@ +import { body, param } from "express-validator"; + +export const createArticleValidators = [ + // title: string mínimo 3 + body("title") + .isString().withMessage("title debe ser un string") // ← comprueba tipo + .trim() // ← recorta espacios + .isLength({ min: 10 }).withMessage("title mínimo 10 caracteres"), + + // description: string mínimo 3 + body("description") + .isString().withMessage("description debe ser un string") + .trim() + .isLength({ min: 3 }).withMessage("description mínimo 3 caracteres"), + + // category: Validación con enum + body("category") + .isString().withMessage("category debe ser un string") + .trim() + .isIn(['Fauna Abisal', 'Ecosistemas', 'Exploración', 'Conservación']) + .withMessage("category debe ser uno de los siguientes: 'Fauna Abisal', 'Ecosistemas', 'Exploración', 'Conservación'"), + + body("category") + .isString().withMessage("category debe ser un string") + .trim() + .isLength({ min: 6 }).withMessage("category mínimo 6 caracteres"), + + // species: string mínimo 6 + body("species") + .isString().withMessage("species debe ser un string") + .trim() + .isLength({ min: 6 }).withMessage("species mínimo 6 caracteres"), + + // image: opcional, si viene debe ser URL + body("image") + .optional() // ← no obligatorio + .isURL().withMessage("image debe ser una URL válida"), + + // references: opcional, si viene mínimo 6 + body("references") + .optional() + .isString().withMessage("references debe ser un string") + .trim() + .isLength({ min: 6 }).withMessage("references mínimo 6 caracteres"), +]; + +/** + * ✅ Validadores para ACTUALIZAR artículo (PUT /articles/:id) + * - :id en params debe ser entero positivo + * - Todos los campos del body son OPCIONALES, pero si vienen, se validan. + */ +export const updateArticleValidators = [ + // Validamos el parámetro :id + param("id") + .isInt({ min: 1 }).withMessage("id debe ser un entero positivo"), + + // El resto igual que create, pero todos .optional() + body("title") + .optional() + .isString().withMessage("title debe ser un string") + .trim() + .isLength({ min: 3 }).withMessage("title mínimo 3 caracteres"), + + body("description") + .optional() + .isString().withMessage("description debe ser un string") + .trim() + .isLength({ min: 3 }).withMessage("description mínimo 3 caracteres"), + + body("content") + .optional() + .isString().withMessage("content debe ser un string") + .trim() + .isLength({ min: 6 }).withMessage("content mínimo 6 caracteres"), + + body("category") + .optional() + .isString().withMessage("category debe ser un string") + .trim() + .isIn(['Fauna Abisal', 'Ecosistemas', 'Exploración', 'Conservación']) + .withMessage("category debe ser uno de los siguientes: 'Fauna Abisal', 'Ecosistemas', 'Exploración', 'Conservación'"), + + + body("species") + .optional() + .isString().withMessage("species debe ser un string") + .trim() + .isLength({ min: 6 }).withMessage("species mínimo 6 caracteres"), + + body("image") + .optional() + .isURL().withMessage("image debe ser una URL válida"), + + body("references") + .optional() + .isString().withMessage("references debe ser un string") + .trim() + .isLength({ min: 6 }).withMessage("references mínimo 6 caracteres"), +]; + +/** + * ✅ Validadores para OBTENER por id (GET /articles/:id) o borrar + * - Solo chequea que :id sea un entero positivo. + */ +export const idParamValidators = [ + param("id") + .isInt({ min: 1 }).withMessage("id debe ser un entero positivo"), +]; \ No newline at end of file diff --git a/src/validators/passwordResetValidators.ts b/src/validators/passwordResetValidators.ts new file mode 100644 index 0000000..0cf565a --- /dev/null +++ b/src/validators/passwordResetValidators.ts @@ -0,0 +1,16 @@ +import { body } from "express-validator"; + +export const forgotValidator = [ + body("email").trim().toLowerCase().isEmail().withMessage("Email inválido"), +]; + +export const resetValidator = [ + body("token").isString().isLength({ min: 10 }).withMessage("Token inválido"), + body("newPassword") + .isString() + .isLength({ min: 8 }).withMessage("La contraseña debe tener mínimo 8 caracteres") + .matches(/[a-z]/).withMessage("Debe incluir minúscula") + .matches(/[A-Z]/).withMessage("Debe incluir mayúscula") + .matches(/\d/).withMessage("Debe incluir número") + .matches(/[^\w\s]/).withMessage("Debe incluir símbolo"), +]; diff --git a/src/validators/userValidators.ts b/src/validators/userValidators.ts new file mode 100644 index 0000000..fab7ac9 --- /dev/null +++ b/src/validators/userValidators.ts @@ -0,0 +1,37 @@ +import { body } from "express-validator"; + +const emailRule = body("email") + .trim() + .toLowerCase() + .isEmail() + .withMessage("Email inválido") + .matches(/@/) + .withMessage("El email debe contener '@'"); + +const passwordRule = body("password") + .isString() + .isLength({ min: 8, max: 255 }) + .withMessage("La contraseña debe tener al menos 8 caracteres"); + +export const registerValidator = [ + body("username") + .trim() + .isLength({ min: 2, max: 50 }) + .withMessage("username debe tener mínimo 2 caracteres"), + body("name") + .trim() + .isLength({ min: 2 }) + .withMessage("name es requerido"), + body("last_name") + .trim() + .isLength({ min: 2 }) + .withMessage("last_name es requerido"), + emailRule, + passwordRule, + body("role") + .optional() + .isIn(["user", "admin"]) + .withMessage("role debe ser 'user' o 'admin'"), +]; + +export const loginValidator = [emailRule, passwordRule]; diff --git a/test/article.test.ts b/test/article.test.ts new file mode 100644 index 0000000..78100af --- /dev/null +++ b/test/article.test.ts @@ -0,0 +1,242 @@ +import supertest from 'supertest'; +import { app } from '../src/app'; // Asegúrate de que la ruta sea correcta a tu archivo principal de la aplicación +import jwt from 'jsonwebtoken'; + + +const request = supertest(app); +beforeAll(() => { + console.log('El servidor de pruebas se está levantando...'); +}); + +// Variables globales para los tests +let adminToken: string; // Token del administrador +let adminUserId: string; // ID del administrador (decodificado del token) +let seededArticleId: string; // ID del artículo creado para pruebas +// Función para generar datos de prueba de artículos +function makeArticleData(overrides: Partial> = {}) { + const longContent = 'Este es un contenido de prueba suficientemente largo para pasar validaciones de longitud. '.repeat(3).trim(); // Usamos `.trim()` para evitar espacios adicionales + + return { + title: `Artículo de prueba ${Date.now()}`, + description: 'Una descripción válida para el artículo de prueba', + content: longContent, // Ahora aseguramos que no haya espacios extra + category: "Fauna Abisal", + species: 'Animal', + image: 'https://ejemplo.com/imagen.jpg', + references: 'https://ejemplo.com/ref', + ...overrides, + }; +} + +// Antes de todos los tests +beforeAll(async () => { + // Crear un usuario admin + const adminUser = { + username: `admin_${Date.now()}`, + name: 'Admin', + last_name: 'User', + email: `admin_${Date.now()}@example.com`, + password: 'password123', + role: 'admin', + }; + + // Registramos al admin + const resRegister = await request.post('/auth/register').send(adminUser); + expect(resRegister.status).toBe(201); + expect(resRegister.body.token).toBeDefined(); + adminToken = resRegister.body.token; + + // Decodificamos el token para obtener el userId + const decoded = jwt.decode(adminToken) as any; + adminUserId = decoded?.userId?.toString() ?? decoded?.userId; + expect(adminUserId).toBeTruthy(); + + // Sembramos un artículo para usarlo en GET/PUT/DELETE + const seedData = makeArticleData({ title: 'Artículo sembrado para pruebas' }); + const resSeed = await request + .post('/article') + .set('Authorization', `Bearer ${adminToken}`) + .send(seedData); + expect(resSeed.status).toBe(201); + expect(resSeed.body.id).toBeDefined(); + seededArticleId = resSeed.body.id.toString(); +}); + +// ----------------- +// POST /article +// ----------------- + +describe('POST /article', () => { + it('crea un artículo y asigna creator_id automáticamente', async () => { + const articleData = makeArticleData({ title: 'Nuevo Artículo de Prueba (POST OK)' }); + + const res = await request + .post('/article') + .set('Authorization', `Bearer ${adminToken}`) + .send(articleData); + + expect(res.status).toBe(201); + expect(res.body).toHaveProperty('id'); + // Cambia en tu test, donde haces el `toMatchObject` +expect(res.body).toMatchObject({ + title: articleData.title, + description: articleData.description, + content: articleData.content, + category: articleData.category, + species: articleData.species, + image: articleData.image, + references: articleData.references, + creator_id: expect.any(Number), // Cambié de string a Number +}); + + }); + + it('devuelve 422 si faltan campos requeridos (description, por ejemplo)', async () => { + const articleData = makeArticleData({ description: '' }); + + const res = await request + .post('/article') + .set('Authorization', `Bearer ${adminToken}`) + .send(articleData); + + expect(res.status).toBe(422); + expect(res.body.message).toBe('Faltan datos necesarios'); + }); + + it('devuelve 401 si NO estás autenticado (si el middleware protege la ruta)', async () => { + const articleData = makeArticleData({ title: 'Debe fallar por no auth' }); + + const res = await request.post('/article').send(articleData); + + expect(res.status).toBe(401); + expect(res.body.message).toMatch(/token/i); + }); +}); + +// ----------------- +// GET /articles +// ----------------- + +describe('GET /article', () => { + it('devuelve 200 y un array de artículos', async () => { + const res = await request.get('/article'); + console.log(res.body); // Imprime la respuesta de la API para ver qué se está recibiendo + + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + expect(res.body.length).toBeGreaterThan(0); // tenemos al menos el sembrado + }); +}); + +// -------------------------- +// GET /article/:id (uno) +// -------------------------- + + +describe('GET /article/:id', () => { +it('devuelve 200 y el artículo si existe', async () => { +const res = await request.get(`/article/${seededArticleId}`); +expect(res.status).toBe(200); +expect(res.body).toHaveProperty('id'); +expect(String(res.body.id)).toBe(String(seededArticleId)); +}); + + +it('devuelve 404 si el artículo no existe', async () => { +const res = await request.get('/article/99999999'); // id grande que no exista +expect(res.status).toBe(404); +expect(res.body.message).toBe('Artículo no encontrado'); +}); +}); + +// -------------------------- +// PUT /article/:id (update) +// -------------------------- + + +describe('PUT /article/:id', () => { +it('actualiza campos permitidos y devuelve 200 con el artículo actualizado', async () => { +const newTitle = 'Título actualizado vía PUT'; + + +const res = await request +.put(`/article/${seededArticleId}`) +.set('Authorization', `Bearer ${adminToken}`) +.send({ title: newTitle, category: 'Fauna Abisal' }); + + +// Tu controlador devuelve { message, article } +expect(res.status).toBe(200); +expect(res.body.message).toBe('Artículo actualizado correctamente'); +expect(res.body.article).toBeDefined(); +expect(res.body.article.title).toBe(newTitle); +expect(res.body.article.category).toBe("Fauna Abisal"); +}); + + +it('devuelve 404 si intentas actualizar un artículo que no existe', async () => { +const res = await request +.put('/article/99999999') +.set('Authorization', `Bearer ${adminToken}`) +.send({ title: 'No debería existir' }); + + +expect(res.status).toBe(404); +expect(res.body.message).toBe('Artículo no encontrado'); +}); + + +it('devuelve 401 si no envías token (si la ruta está protegida por middleware)', async () => { +const res = await request +.put(`/article/${seededArticleId}`) +.send({ title: 'Debe fallar por no auth' }); + +expect([200, 401]).toContain(res.status); +}); +}); + +// ----------------------------- +// DELETE /article/:id (delete) +// ----------------------------- + + +describe('DELETE /article/:id', () => { +it('borra un artículo existente y devuelve 200', async () => { +// Creamos un artículo NUEVO solo para borrarlo aquí +const temp = await request +.post('/article') +.set('Authorization', `Bearer ${adminToken}`) +.send(makeArticleData({ title: 'Para borrar en DELETE' })); + + +expect(temp.status).toBe(201); +const idToDelete = temp.body.id; + + +const res = await request +.delete(`/article/${idToDelete}`) +.set('Authorization', `Bearer ${adminToken}`); + + +expect(res.status).toBe(200); +expect(res.body.message).toBe('El articulo esta eliminado correctamente'); +}); + + +it('devuelve 404 si intentas borrar un artículo que no existe', async () => { +const res = await request +.delete('/article/99999999') +.set('Authorization', `Bearer ${adminToken}`); + + +expect(res.status).toBe(404); +expect(res.body.message).toBe('Artículo no encontrado'); +}); + + +it('devuelve 401 si no envías token (si hay middleware de auth)', async () => { +const res = await request.delete(`/article/${seededArticleId}`); + +expect([200, 401, 404]).toContain(res.status); +}); +}); \ No newline at end of file diff --git a/test/auth.test.ts b/test/auth.test.ts new file mode 100644 index 0000000..8a08a78 --- /dev/null +++ b/test/auth.test.ts @@ -0,0 +1,131 @@ +import supertest from "supertest"; +import { app } from "../src/app.js"; // Importamos la app de express + + +// Creamos un agente de supertest para hacer peticiones +const request = supertest(app); +// --- TESTS PARA REGISTRO --- +describe("POST /auth/register", () => { + + it("debería registrar un nuevo usuario y devolver un token", async () => { + const newUser = { + username: "testuser", + name: "Test", + last_name: "User", + email: "test@example.com", + password: "password123", + }; + + const response = await request.post("/auth/register").send(newUser); + + // 1. Comprobamos el Status Code + expect(response.status).toBe(201); + + // 2. Comprobamos que la respuesta tenga un token + expect(response.body).toHaveProperty("token"); + + // 3. Comprobamos que el usuario devuelto sea el correcto (sin la contraseña) + // expect(response.body.user).toMatchObject({ + // username: newUser.username, + // email: newUser.email, + // name: newUser.name, + // last_name: newUser.last_name + // }); + }); + + it("debería devolver un error 409 si el email ya existe", async () => { + // Primero creamos un usuario + const user = { + username: "testuser", + name: "Test", + last_name: "User", + email: "test@example.com", + password: "password123", + }; + // await UserModel.create(user); + await request.post("/auth/register").send(user); + + // Intentamos registrarlo de nuevo con el mismo email + const response = await request.post("/auth/register").send({ + ...user, + username: "anotheruser" // cambiamos el username para que el error sea solo por el email + }); + + // Comprobamos el error + expect(response.status).toBe(409); + expect(response.body.message).toBe("El email ya está registrado"); + }); + + it("debería devolver un error 422 por datos de validación inválidos", async () => { + const invalidUser = { + username: "a", // muy corto + name: "Test", + last_name: "User", + email: "not-an-email", // email inválido + password: "123", // muy corta + }; + + const response = await request.post("/auth/register").send(invalidUser); + + expect(response.status).toBe(422); // 422 Unprocessable Entity + expect(response.body.errors).toBeInstanceOf(Array); + // Comprobamos que contiene al menos un error para el campo 'username' + expect(response.body.errors.some((err: any) => err.field === "username")).toBe(true); + }); +}); + +// --- TESTS PARA LOGIN --- +describe("POST /auth/login", () => { + + // Preparamos un usuario en la BD antes de cada test de login + beforeEach(async () => { + const user = { + username: "loginuser", + name: "Login", + last_name: "User", + email: "login@example.com", + password: "password123", // La contraseña se hasheará en el controlador + role: "user" + }; + // Simulamos el proceso de registro para tener una contraseña hasheada + await request.post("/auth/register").send(user); + }); + + it("debería loguear a un usuario existente y devolver un token", async () => { + const credentials = { + email: "login@example.com", + password: "password123", + }; + + const response = await request.post("/auth/login").send(credentials); + + expect(response.status).toBe(200); + expect(response.body).toHaveProperty("token"); + // expect(response.body.user.email).toBe(credentials.email); + }); + + it("debería devolver un error 401 con contraseña incorrecta", async () => { + const credentials = { + email: "login@example.com", + password: "wrongpassword", + }; + + const response = await request.post("/auth/login").send(credentials); + + expect(response.status).toBe(401); + expect(response.body.message).toBe("Email o contraseña incorrectos"); + }); + + it("debería devolver un error 404 si el usuario no existe", async () => { + const credentials = { + email: "nonexistent@example.com", + password: "anypassword", + }; + + const response = await request.post("/auth/login").send(credentials); + + expect(response.status).toBe(401); + expect(response.body.message).toBe("Email o contraseña incorrectos"); + }); + +}); \ No newline at end of file diff --git a/test/jest.setup.ts b/test/jest.setup.ts new file mode 100644 index 0000000..5051582 --- /dev/null +++ b/test/jest.setup.ts @@ -0,0 +1,21 @@ +process.env.NODE_ENV = "test"; + +// Cargo variables de .env.test +import dotenv from "dotenv"; +dotenv.config({ path: ".env.test" }); + +// Importo conexión y MODELOS (¡importar los modelos es clave!) +import db_connection from "../src/database/db_connection.js"; + + + +// Antes de todo: conectar y crear tablas desde modelos +beforeAll(async () => { + await db_connection.authenticate(); + await db_connection.sync({ force: true }); // borra si hay y recrea limpio para test +}); + +// Después de todo: cerrar conexión +afterAll(async () => { + await db_connection.close(); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..7cfe2d5 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + /* --- Rutas --- */ + "rootDir": "./src", // Tu código .ts vive en src + "outDir": "./dist", // El JS compilado saldrá en dist + + /* --- Módulos/Entorno (Node ESM) --- */ + "module": "Nodenext", // ESM nativo de Node + "moduleResolution": "Nodenext", // Resolver imports al estilo Node ESM + "target": "ES2022", // JS moderno compatible con Node 22 + "lib": ["ES2022"], // Librerías base + "types": ["node", "jest"], + "resolveJsonModule": true, // Permite import de .json (opcional) + + /* --- Compatibilidad y calidad --- */ + "esModuleInterop": true, // Imports por defecto más cómodos + "skipLibCheck": true, // Acelera compilación + "strict": true, // Reglas estrictas + "forceConsistentCasingInFileNames": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "isolatedModules": true, + + /* --- Debug --- */ + "sourceMap": true + }, + "include": ["src/**/*"], // <--- AÑADE "test/**/*" AQUÍ + "exclude": ["node_modules", "test/**/*","dist"] + +} diff --git a/validators/.gitkeep b/validators/.gitkeep deleted file mode 100644 index e69de29..0000000