diff --git a/Dockerfile b/Dockerfile index 821b4a5..8cc148a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,4 +40,4 @@ EXPOSE 3000 USER node -CMD ["sh", "-c", "npx prisma db push && node dist/index.js"] +CMD ["sh", "-c", "npx prisma migrate deploy && node dist/index.js"] diff --git a/Dockerfile.dev b/Dockerfile.dev index 319ed6d..81aa40e 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -19,4 +19,4 @@ ENV NODE_ENV=development EXPOSE 3000 -CMD ["sh", "-c", "npx prisma db push && npx nodemon"] +CMD ["sh", "-c", "npx prisma migrate deploy && npx nodemon"] diff --git a/docker-compose.yml b/docker-compose.yml index 57867f4..168caac 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,15 +23,19 @@ services: - JWT_SECRET=${JWT_SECRET:-} - JWT_ACCESS_EXPIRY=${JWT_ACCESS_EXPIRY:-15m} - JWT_REFRESH_EXPIRY_SECONDS=${JWT_REFRESH_EXPIRY_SECONDS:-604800} - - RESET_TOKEN_TTL_SECONDS=${RESET_TOKEN_TTL_SECONDS:-900} + - RESET_TOKEN_TTL_SECONDS=${RESET_TOKEN_TTL_SECONDS:-3600} - CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:5173} - - OAUTH_FRONTEND_URL=${OAUTH_FRONTEND_URL:-http://localhost:5173} + - CORS_CREDENTIALS=${CORS_CREDENTIALS:-true} + - FRONTEND_URL=${FRONTEND_URL:-http://localhost:5173} - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-} - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-} - GOOGLE_CALLBACK_URL=${GOOGLE_CALLBACK_URL:-http://localhost:3000/api/v1/auth/oauth/google/callback} - GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID:-} - GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-} - GITHUB_CALLBACK_URL=${GITHUB_CALLBACK_URL:-http://localhost:3000/api/v1/auth/oauth/github/callback} + - RESEND_API_KEY=${RESEND_API_KEY:-} + - RESET_PASSWORD_EMAIL=${RESET_PASSWORD_EMAIL:-} + - RESET_PASSWORD_TEMPLATE_ID=${RESET_PASSWORD_TEMPLATE_ID:-} - NODE_TLS_REJECT_UNAUTHORIZED=0 restart: unless-stopped depends_on: diff --git a/docs/migrations.md b/docs/migrations.md new file mode 100644 index 0000000..627c7b6 --- /dev/null +++ b/docs/migrations.md @@ -0,0 +1,26 @@ +# Prisma Migrations + +## How It Works + +1. You edit `prisma/schema.prisma` +2. You run `npm run db:migrate` — this generates a new SQL migration file and applies it to your local DB +3. You commit the generated `prisma/migrations/_/migration.sql` +4. In Docker, `prisma migrate deploy` runs on startup and applies any pending migrations + +## Creating a Migration + +```sh +npm run db:migrate +``` + +Prisma will prompt you for a migration name (e.g. `add_user_avatar`). This creates `prisma/migrations/_add_user_avatar/migration.sql`. + +## Deploying + +Both `Dockerfile` and `Dockerfile.dev` run `prisma migrate deploy` on container start. This only applies migrations that haven't been applied yet. + +## Key Rules + +- **Always** commit migration files to version control +- **Never** edit a migration file after it has been applied to any environment +- Run `npm run db:migrate` locally, not in Docker — Docker only runs `deploy` diff --git a/jest.config.js b/jest.config.js index d39db9a..edb0be9 100644 --- a/jest.config.js +++ b/jest.config.js @@ -19,6 +19,8 @@ module.exports = { '^@models/(.*)$': '/src/models/$1', '^@constants/(.*)$': '/src/constants/$1', '^@generated/(.*)$': '/src/generated/$1', + '^@queues/(.*)$': '/src/queues/$1', + '^@workers/(.*)$': '/src/workers/$1', }, clearMocks: true, collectCoverageFrom: [ diff --git a/overrides/api.dev.yml b/overrides/api.dev.yml index d5072f3..a90a072 100644 --- a/overrides/api.dev.yml +++ b/overrides/api.dev.yml @@ -16,4 +16,4 @@ services: - ${API_DIR}/prisma:/app/prisma - ${API_DIR}/prisma.config.ts:/app/prisma.config.ts - ${API_DIR}/nodemon.json:/app/nodemon.json - - ${API_DIR}/tsconfig.json:/app/tsconfig.json \ No newline at end of file + - ${API_DIR}/tsconfig.json:/app/tsconfig.json diff --git a/package-lock.json b/package-lock.json index ff92d4b..8f78c98 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@prisma/adapter-pg": "^7.4.2", "@prisma/client": "^7.4.2", "argon2": "^0.44.0", + "bullmq": "^5.71.0", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2", @@ -27,6 +28,7 @@ "pino-http": "^11.0.0", "pino-pretty": "^13.1.3", "prisma": "^7.4.2", + "resend": "^6.9.3", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1", "zod": "^4.3.6" @@ -674,17 +676,17 @@ "license": "Apache-2.0" }, "node_modules/@commitlint/cli": { - "version": "20.4.3", - "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-20.4.3.tgz", - "integrity": "sha512-Z37EMoDT7+Upg500vlr/vZrgRsb6Xc5JAA3Tv7BYbobnN/ZpqUeZnSLggBg2+1O+NptRDtyujr2DD1CPV2qwhA==", + "version": "20.4.4", + "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-20.4.4.tgz", + "integrity": "sha512-GLMNQHYGcn0ohL2HMlAnXcD1PS2vqBBGbYKlhrRPOYsWiRoLWtrewsR3uKRb9v/IdS+qOS0vqJQ64n1g8VPKFw==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/format": "^20.4.3", - "@commitlint/lint": "^20.4.3", - "@commitlint/load": "^20.4.3", - "@commitlint/read": "^20.4.3", - "@commitlint/types": "^20.4.3", + "@commitlint/format": "^20.4.4", + "@commitlint/lint": "^20.4.4", + "@commitlint/load": "^20.4.4", + "@commitlint/read": "^20.4.4", + "@commitlint/types": "^20.4.4", "tinyexec": "^1.0.0", "yargs": "^17.0.0" }, @@ -696,13 +698,13 @@ } }, "node_modules/@commitlint/config-conventional": { - "version": "20.4.3", - "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-20.4.3.tgz", - "integrity": "sha512-9RtLySbYQAs8yEqWEqhSZo9nYhbm57jx7qHXtgRmv/nmeQIjjMcwf6Dl+y5UZcGWgWx435TAYBURONaJIuCjWg==", + "version": "20.4.4", + "resolved": "https://registry.npmjs.org/@commitlint/config-conventional/-/config-conventional-20.4.4.tgz", + "integrity": "sha512-Usg+XXbPNG2GtFWTgRURNWCge1iH1y6jQIvvklOdAbyn2t8ajfVwZCnf5t5X4gUsy17BOiY+myszGsSMIvhOVA==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^20.4.3", + "@commitlint/types": "^20.4.4", "conventional-changelog-conventionalcommits": "^9.2.0" }, "engines": { @@ -710,13 +712,13 @@ } }, "node_modules/@commitlint/config-validator": { - "version": "20.4.3", - "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-20.4.3.tgz", - "integrity": "sha512-jCZpZFkcSL3ZEdL5zgUzFRdytv3xPo8iukTe9VA+QGus/BGhpp1xXSVu2B006GLLb2gYUAEGEqv64kTlpZNgmA==", + "version": "20.4.4", + "resolved": "https://registry.npmjs.org/@commitlint/config-validator/-/config-validator-20.4.4.tgz", + "integrity": "sha512-K8hMS9PTLl7EYe5vWtSFQ/sgsV2PHUOtEnosg8k3ZQxCyfKD34I4C7FxWEfRTR54rFKeUYmM3pmRQqBNQeLdlw==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^20.4.3", + "@commitlint/types": "^20.4.4", "ajv": "^8.11.0" }, "engines": { @@ -724,15 +726,15 @@ } }, "node_modules/@commitlint/cz-commitlint": { - "version": "20.4.3", - "resolved": "https://registry.npmjs.org/@commitlint/cz-commitlint/-/cz-commitlint-20.4.3.tgz", - "integrity": "sha512-urqoAyFFGwt069p0d5rSqbDnHwE28jpDYsZJAo/oqG2yz6dkYB0lyahq+NoJVNjREpWsmRHH5hC+JhXpxyEXqQ==", + "version": "20.4.4", + "resolved": "https://registry.npmjs.org/@commitlint/cz-commitlint/-/cz-commitlint-20.4.4.tgz", + "integrity": "sha512-dQuLSHrLbeLx/7JI0bCeYI2sWmmEs8rCUwIZu0d6p9C4OjnjPcLxQOBRaFwiqXGrjr1Dctb2dIzNtymt06vTQA==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/ensure": "^20.4.3", - "@commitlint/load": "^20.4.3", - "@commitlint/types": "^20.4.3", + "@commitlint/ensure": "^20.4.4", + "@commitlint/load": "^20.4.4", + "@commitlint/types": "^20.4.4", "is-plain-obj": "^4.1.0", "picocolors": "^1.1.1", "word-wrap": "^1.2.5" @@ -746,13 +748,13 @@ } }, "node_modules/@commitlint/ensure": { - "version": "20.4.3", - "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-20.4.3.tgz", - "integrity": "sha512-WcXGKBNn0wBKpX8VlXgxqedyrLxedIlLBCMvdamLnJFEbUGJ9JZmBVx4vhLV3ZyA8uONGOb+CzW0Y9HDbQ+ONQ==", + "version": "20.4.4", + "resolved": "https://registry.npmjs.org/@commitlint/ensure/-/ensure-20.4.4.tgz", + "integrity": "sha512-QivV0M1MGL867XCaF+jJkbVXEPKBALhUUXdjae66hes95aY1p3vBJdrcl3x8jDv2pdKWvIYIz+7DFRV/v0dRkA==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^20.4.3", + "@commitlint/types": "^20.4.4", "lodash.camelcase": "^4.3.0", "lodash.kebabcase": "^4.1.1", "lodash.snakecase": "^4.1.1", @@ -774,13 +776,13 @@ } }, "node_modules/@commitlint/format": { - "version": "20.4.3", - "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-20.4.3.tgz", - "integrity": "sha512-UDJVErjLbNghop6j111rsHJYGw6MjCKAi95K0GT2yf4eeiDHy3JDRLWYWEjIaFgO+r+dQSkuqgJ1CdMTtrvHsA==", + "version": "20.4.4", + "resolved": "https://registry.npmjs.org/@commitlint/format/-/format-20.4.4.tgz", + "integrity": "sha512-jLi/JBA4GEQxc5135VYCnkShcm1/rarbXMn2Tlt3Si7DHiiNKHm4TaiJCLnGbZ1r8UfwDRk+qrzZ80kwh08Aow==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^20.4.3", + "@commitlint/types": "^20.4.4", "picocolors": "^1.1.1" }, "engines": { @@ -788,13 +790,13 @@ } }, "node_modules/@commitlint/is-ignored": { - "version": "20.4.3", - "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-20.4.3.tgz", - "integrity": "sha512-W5VQKZ7fdJ1X3Tko+h87YZaqRMGN1KvQKXyCM8xFdxzMIf1KCZgN4uLz3osLB1zsFcVS4ZswHY64LI26/9ACag==", + "version": "20.4.4", + "resolved": "https://registry.npmjs.org/@commitlint/is-ignored/-/is-ignored-20.4.4.tgz", + "integrity": "sha512-y76rT8yq02x+pMDBI2vY4y/ByAwmJTkta/pASbgo8tldBiKLduX8/2NCRTSCjb3SumE5FBeopERKx3oMIm8RTQ==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^20.4.3", + "@commitlint/types": "^20.4.4", "semver": "^7.6.0" }, "engines": { @@ -802,32 +804,32 @@ } }, "node_modules/@commitlint/lint": { - "version": "20.4.3", - "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-20.4.3.tgz", - "integrity": "sha512-CYOXL23e+nRKij81+d0+dymtIi7Owl9QzvblJYbEfInON/4MaETNSLFDI74LDu+YJ0ML5HZyw9Vhp9QpckwQ0A==", + "version": "20.4.4", + "resolved": "https://registry.npmjs.org/@commitlint/lint/-/lint-20.4.4.tgz", + "integrity": "sha512-svOEW+RptcNpXKE7UllcAsV0HDIdOck9reC2TP1QA6K5Fo0xxQV+QPjV8Zqx9g6X/hQBkF2S9ZQZ78Xrv1Eiog==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/is-ignored": "^20.4.3", - "@commitlint/parse": "^20.4.3", - "@commitlint/rules": "^20.4.3", - "@commitlint/types": "^20.4.3" + "@commitlint/is-ignored": "^20.4.4", + "@commitlint/parse": "^20.4.4", + "@commitlint/rules": "^20.4.4", + "@commitlint/types": "^20.4.4" }, "engines": { "node": ">=v18" } }, "node_modules/@commitlint/load": { - "version": "20.4.3", - "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-20.4.3.tgz", - "integrity": "sha512-3cdJOUVP+VcgHa7bhJoWS+Z8mBNXB5aLWMBu7Q7uX8PSeWDzdbrBlR33J1MGGf7r1PZDp+mPPiFktk031PgdRw==", + "version": "20.4.4", + "resolved": "https://registry.npmjs.org/@commitlint/load/-/load-20.4.4.tgz", + "integrity": "sha512-kvFrzvoIACa/fMjXEP0LNEJB1joaH3q3oeMJsLajXE5IXjYrNGVcW1ZFojXUruVJ7odTZbC3LdE/6+ONW4f2Dg==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/config-validator": "^20.4.3", + "@commitlint/config-validator": "^20.4.4", "@commitlint/execute-rule": "^20.0.0", - "@commitlint/resolve-extends": "^20.4.3", - "@commitlint/types": "^20.4.3", + "@commitlint/resolve-extends": "^20.4.4", + "@commitlint/types": "^20.4.4", "cosmiconfig": "^9.0.1", "cosmiconfig-typescript-loader": "^6.1.0", "is-plain-obj": "^4.1.0", @@ -849,13 +851,13 @@ } }, "node_modules/@commitlint/parse": { - "version": "20.4.3", - "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-20.4.3.tgz", - "integrity": "sha512-hzC3JCo3zs3VkQ833KnGVuWjWIzR72BWZWjQM7tY/7dfKreKAm7fEsy71tIFCRtxf2RtMP2d3RLF1U9yhFSccA==", + "version": "20.4.4", + "resolved": "https://registry.npmjs.org/@commitlint/parse/-/parse-20.4.4.tgz", + "integrity": "sha512-AjfgOgrjEozeQNzhFu1KL5N0nDx4JZmswVJKNfOTLTUGp6xODhZHCHqb//QUHKOzx36If5DQ7tci2o7szYxu1A==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/types": "^20.4.3", + "@commitlint/types": "^20.4.4", "conventional-changelog-angular": "^8.2.0", "conventional-commits-parser": "^6.3.0" }, @@ -864,15 +866,15 @@ } }, "node_modules/@commitlint/read": { - "version": "20.4.3", - "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-20.4.3.tgz", - "integrity": "sha512-j42OWv3L31WfnP8WquVjHZRt03w50Y/gEE8FAyih7GQTrIv2+pZ6VZ6pWLD/ml/3PO+RV2SPtRtTp/MvlTb8rQ==", + "version": "20.4.4", + "resolved": "https://registry.npmjs.org/@commitlint/read/-/read-20.4.4.tgz", + "integrity": "sha512-jvgdAQDdEY6L8kCxOo21IWoiAyNFzvrZb121wU2eBxI1DzWAUZgAq+a8LlJRbT0Qsj9INhIPVWgdaBbEzlF0dQ==", "dev": true, "license": "MIT", "dependencies": { "@commitlint/top-level": "^20.4.3", - "@commitlint/types": "^20.4.3", - "git-raw-commits": "^4.0.0", + "@commitlint/types": "^20.4.4", + "git-raw-commits": "^5.0.0", "minimist": "^1.2.8", "tinyexec": "^1.0.0" }, @@ -881,14 +883,14 @@ } }, "node_modules/@commitlint/resolve-extends": { - "version": "20.4.3", - "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-20.4.3.tgz", - "integrity": "sha512-QucxcOy+00FhS9s4Uy0OyS5HeUV+hbC6OLqkTSIm6fwMdKva+OEavaCDuLtgd9akZZlsUo//XzSmPP3sLKBPog==", + "version": "20.4.4", + "resolved": "https://registry.npmjs.org/@commitlint/resolve-extends/-/resolve-extends-20.4.4.tgz", + "integrity": "sha512-pyOf+yX3c3m/IWAn2Jop+7s0YGKPQ8YvQaxt9IQxnLIM3yZAlBdkKiQCT14TnrmZTkVGTXiLtckcnFTXYwlY0A==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/config-validator": "^20.4.3", - "@commitlint/types": "^20.4.3", + "@commitlint/config-validator": "^20.4.4", + "@commitlint/types": "^20.4.4", "global-directory": "^4.0.1", "import-meta-resolve": "^4.0.0", "lodash.mergewith": "^4.6.2", @@ -899,16 +901,16 @@ } }, "node_modules/@commitlint/rules": { - "version": "20.4.3", - "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-20.4.3.tgz", - "integrity": "sha512-Yuosd7Grn5qiT7FovngXLyRXTMUbj9PYiSkvUgWK1B5a7+ZvrbWDS7epeUapYNYatCy/KTpPFPbgLUdE+MUrBg==", + "version": "20.4.4", + "resolved": "https://registry.npmjs.org/@commitlint/rules/-/rules-20.4.4.tgz", + "integrity": "sha512-PmUp8QPLICn9w05dAx5r1rdOYoTk7SkfusJJh5tP3TqHwo2mlQ9jsOm8F0HSXU9kuLfgTEGNrunAx/dlK/RyPQ==", "dev": true, "license": "MIT", "dependencies": { - "@commitlint/ensure": "^20.4.3", + "@commitlint/ensure": "^20.4.4", "@commitlint/message": "^20.4.3", "@commitlint/to-lines": "^20.0.0", - "@commitlint/types": "^20.4.3" + "@commitlint/types": "^20.4.4" }, "engines": { "node": ">=v18" @@ -938,9 +940,9 @@ } }, "node_modules/@commitlint/types": { - "version": "20.4.3", - "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-20.4.3.tgz", - "integrity": "sha512-51OWa1Gi6ODOasPmfJPq6js4pZoomima4XLZZCrkldaH2V5Nb3bVhNXPeT6XV0gubbainSpTw4zi68NqAeCNCg==", + "version": "20.4.4", + "resolved": "https://registry.npmjs.org/@commitlint/types/-/types-20.4.4.tgz", + "integrity": "sha512-dwTGzyAblFXHJNBOgrTuO5Ee48ioXpS5XPRLLatxhQu149DFAHUcB3f0Q5eea3RM4USSsP1+WVT2dBtLVod4fg==", "dev": true, "license": "MIT", "dependencies": { @@ -951,6 +953,33 @@ "node": ">=v18" } }, + "node_modules/@conventional-changelog/git-client": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@conventional-changelog/git-client/-/git-client-2.6.0.tgz", + "integrity": "sha512-T+uPDciKf0/ioNNDpMGc8FDsehJClZP0yR3Q5MN6wE/Y/1QZ7F+80OgznnTCOlMEG4AV0LvH2UJi3C/nBnaBUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@simple-libs/child-process-utils": "^1.0.0", + "@simple-libs/stream-utils": "^1.2.0", + "semver": "^7.5.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "conventional-commits-filter": "^5.0.0", + "conventional-commits-parser": "^6.3.0" + }, + "peerDependenciesMeta": { + "conventional-commits-filter": { + "optional": true + }, + "conventional-commits-parser": { + "optional": true + } + } + }, "node_modules/@coveritlabs/contracts": { "version": "1.1.0", "resolved": "https://npm.pkg.github.com/download/@coveritlabs/contracts/1.1.0/0ece52aa2d9770bcc66cd65efc99a80b2a3769cd", @@ -961,7 +990,7 @@ }, "node_modules/@coveritlabs/git-hooks": { "version": "1.0.0", - "resolved": "git+ssh://git@github.com/CoveritLabs/.github.git#9354ffe73fc9b6e87b63590d2192a21bac8be51f", + "resolved": "git+ssh://git@github.com/CoveritLabs/.github.git#8b96a0dc12c3084dfa9db3085840ff633965a167", "dev": true, "license": "UNLICENSED", "dependencies": { @@ -1029,21 +1058,21 @@ } }, "node_modules/@emnapi/core": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", - "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", + "integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.1.0", + "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz", + "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", "dev": true, "license": "MIT", "optional": true, @@ -1052,9 +1081,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", - "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", "dev": true, "license": "MIT", "optional": true, @@ -1579,17 +1608,17 @@ } }, "node_modules/@jest/console": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.2.0.tgz", - "integrity": "sha512-+O1ifRjkvYIkBqASKWgLxrpEhQAAE7hY77ALLUufSk5717KfOShg6IbqLmdsLMPdUiFvA2kTs0R7YZy+l0IzZQ==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.3.0.tgz", + "integrity": "sha512-PAwCvFJ4696XP2qZj+LAn1BWjZaJ6RjG6c7/lkMaUJnkyMS34ucuIsfqYvfskVNvUI27R/u4P1HMYFnlVXG/Ww==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", - "jest-message-util": "30.2.0", - "jest-util": "30.2.0", + "jest-message-util": "30.3.0", + "jest-util": "30.3.0", "slash": "^3.0.0" }, "engines": { @@ -1673,39 +1702,38 @@ } }, "node_modules/@jest/core": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.2.0.tgz", - "integrity": "sha512-03W6IhuhjqTlpzh/ojut/pDB2LPRygyWX8ExpgHtQA8H/3K7+1vKmcINx5UzeOX1se6YEsBsOHQ1CRzf3fOwTQ==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.3.0.tgz", + "integrity": "sha512-U5mVPsBxLSO6xYbf+tgkymLx+iAhvZX43/xI1+ej2ZOPnPdkdO1CzDmFKh2mZBn2s4XZixszHeQnzp1gm/DIxw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.2.0", + "@jest/console": "30.3.0", "@jest/pattern": "30.0.1", - "@jest/reporters": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", + "@jest/reporters": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.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.2.0", - "jest-config": "30.2.0", - "jest-haste-map": "30.2.0", - "jest-message-util": "30.2.0", + "jest-changed-files": "30.3.0", + "jest-config": "30.3.0", + "jest-haste-map": "30.3.0", + "jest-message-util": "30.3.0", "jest-regex-util": "30.0.1", - "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.2.0", + "jest-resolve": "30.3.0", + "jest-resolve-dependencies": "30.3.0", + "jest-runner": "30.3.0", + "jest-runtime": "30.3.0", + "jest-snapshot": "30.3.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0", + "jest-watcher": "30.3.0", + "pretty-format": "30.3.0", "slash": "^3.0.0" }, "engines": { @@ -1797,9 +1825,9 @@ } }, "node_modules/@jest/diff-sequences": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz", - "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.3.0.tgz", + "integrity": "sha512-cG51MVnLq1ecVUaQ3fr6YuuAOitHK1S4WUJHnsPFE/quQr33ADUx1FfrTCpMCRxvy0Yr9BThKpDjSlcTi91tMA==", "dev": true, "license": "MIT", "engines": { @@ -1807,39 +1835,39 @@ } }, "node_modules/@jest/environment": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.2.0.tgz", - "integrity": "sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.3.0.tgz", + "integrity": "sha512-SlLSF4Be735yQXyh2+mctBOzNDx5s5uLv88/j8Qn1wH679PDcwy67+YdADn8NJnGjzlXtN62asGH/T4vWOkfaw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/fake-timers": "30.2.0", - "@jest/types": "30.2.0", + "@jest/fake-timers": "30.3.0", + "@jest/types": "30.3.0", "@types/node": "*", - "jest-mock": "30.2.0" + "jest-mock": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/expect": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.2.0.tgz", - "integrity": "sha512-V9yxQK5erfzx99Sf+7LbhBwNWEZ9eZay8qQ9+JSC0TrMR1pMDHLMY+BnVPacWU6Jamrh252/IKo4F1Xn/zfiqA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.3.0.tgz", + "integrity": "sha512-76Nlh4xJxk2D/9URCn3wFi98d2hb19uWE1idLsTt2ywhvdOldbw3S570hBgn25P4ICUZ/cBjybrBex2g17IDbg==", "dev": true, "license": "MIT", "dependencies": { - "expect": "30.2.0", - "jest-snapshot": "30.2.0" + "expect": "30.3.0", + "jest-snapshot": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/expect-utils": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.2.0.tgz", - "integrity": "sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.3.0.tgz", + "integrity": "sha512-j0+W5iQQ8hBh7tHZkTQv3q2Fh/M7Je72cIsYqC4OaktgtO7v1So9UTjp6uPBHIaB6beoF/RRsCgMJKvti0wADA==", "dev": true, "license": "MIT", "dependencies": { @@ -1850,18 +1878,18 @@ } }, "node_modules/@jest/fake-timers": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.2.0.tgz", - "integrity": "sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.3.0.tgz", + "integrity": "sha512-WUQDs8SOP9URStX1DzhD425CqbN/HxUYCTwVrT8sTVBfMvFqYt/s61EK5T05qnHu0po6RitXIvP9otZxYDzTGQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", - "@sinonjs/fake-timers": "^13.0.0", + "@jest/types": "30.3.0", + "@sinonjs/fake-timers": "^15.0.0", "@types/node": "*", - "jest-message-util": "30.2.0", - "jest-mock": "30.2.0", - "jest-util": "30.2.0" + "jest-message-util": "30.3.0", + "jest-mock": "30.3.0", + "jest-util": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -1878,16 +1906,16 @@ } }, "node_modules/@jest/globals": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.2.0.tgz", - "integrity": "sha512-b63wmnKPaK+6ZZfpYhz9K61oybvbI1aMcIs80++JI1O1rR1vaxHUCNqo3ITu6NU0d4V34yZFoHMn/uoKr/Rwfw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.3.0.tgz", + "integrity": "sha512-+owLCBBdfpgL3HU+BD5etr1SvbXpSitJK0is1kiYjJxAAJggYMRQz5hSdd5pq1sSggfxPbw2ld71pt4x5wwViA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.2.0", - "@jest/expect": "30.2.0", - "@jest/types": "30.2.0", - "jest-mock": "30.2.0" + "@jest/environment": "30.3.0", + "@jest/expect": "30.3.0", + "@jest/types": "30.3.0", + "jest-mock": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -1908,32 +1936,32 @@ } }, "node_modules/@jest/reporters": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.2.0.tgz", - "integrity": "sha512-DRyW6baWPqKMa9CzeiBjHwjd8XeAyco2Vt8XbcLFjiwCOEKOvy82GJ8QQnJE9ofsxCMPjH4MfH8fCWIHHDKpAQ==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.3.0.tgz", + "integrity": "sha512-a09z89S+PkQnL055bVj8+pe2Caed2PBOaczHcXCykW5ngxX9EWx/1uAwncxc/HiU0oZqfwseMjyhxgRjS49qPw==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", + "@jest/console": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", "@jridgewell/trace-mapping": "^0.3.25", "@types/node": "*", "chalk": "^4.1.2", "collect-v8-coverage": "^1.0.2", "exit-x": "^0.2.2", - "glob": "^10.3.10", + "glob": "^10.5.0", "graceful-fs": "^4.2.11", "istanbul-lib-coverage": "^3.0.0", "istanbul-lib-instrument": "^6.0.0", "istanbul-lib-report": "^3.0.0", "istanbul-lib-source-maps": "^5.0.0", "istanbul-reports": "^3.1.3", - "jest-message-util": "30.2.0", - "jest-util": "30.2.0", - "jest-worker": "30.2.0", + "jest-message-util": "30.3.0", + "jest-util": "30.3.0", + "jest-worker": "30.3.0", "slash": "^3.0.0", "string-length": "^4.0.2", "v8-to-istanbul": "^9.0.1" @@ -2088,13 +2116,13 @@ } }, "node_modules/@jest/snapshot-utils": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.2.0.tgz", - "integrity": "sha512-0aVxM3RH6DaiLcjj/b0KrIBZhSX1373Xci4l3cW5xiUWPctZ59zQ7jj4rqcJQ/Z8JuN/4wX3FpJSa3RssVvCug==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.3.0.tgz", + "integrity": "sha512-ORbRN9sf5PP82v3FXNSwmO1OTDR2vzR2YTaR+E3VkSBZ8zadQE6IqYdYEeFH1NIkeB2HIGdF02dapb6K0Mj05g==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "natural-compare": "^1.4.0" @@ -2195,14 +2223,14 @@ } }, "node_modules/@jest/test-result": { - "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==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.3.0.tgz", + "integrity": "sha512-e/52nJGuD74AKTSe0P4y5wFRlaXP0qmrS17rqOMHeSwm278VyNyXE3gFO/4DTGF9w+65ra3lo3VKj0LBrzmgdQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.2.0", - "@jest/types": "30.2.0", + "@jest/console": "30.3.0", + "@jest/types": "30.3.0", "@types/istanbul-lib-coverage": "^2.0.6", "collect-v8-coverage": "^1.0.2" }, @@ -2211,15 +2239,15 @@ } }, "node_modules/@jest/test-sequencer": { - "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==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.3.0.tgz", + "integrity": "sha512-dgbWy9b8QDlQeRZcv7LNF+/jFiiYHTKho1xirauZ7kVwY7avjFF6uTT0RqlgudB5OuIPagFdVtfFMosjVbk1eA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/test-result": "30.2.0", + "@jest/test-result": "30.3.0", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.2.0", + "jest-haste-map": "30.3.0", "slash": "^3.0.0" }, "engines": { @@ -2227,24 +2255,23 @@ } }, "node_modules/@jest/transform": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.2.0.tgz", - "integrity": "sha512-XsauDV82o5qXbhalKxD7p4TZYYdwcaEXC77PPD2HixEFF+6YGppjrAAQurTl2ECWcEomHBMMNS9AH3kcCFx8jA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.3.0.tgz", + "integrity": "sha512-TLKY33fSLVd/lKB2YI1pH69ijyUblO/BQvCj566YvnwuzoTNr648iE0j22vRvVNk2HsPwByPxATg3MleS3gf5A==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.27.4", - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@jridgewell/trace-mapping": "^0.3.25", "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.2.0", + "jest-haste-map": "30.3.0", "jest-regex-util": "30.0.1", - "jest-util": "30.2.0", - "micromatch": "^4.0.8", + "jest-util": "30.3.0", "pirates": "^4.0.7", "slash": "^3.0.0", "write-file-atomic": "^5.0.1" @@ -2330,9 +2357,9 @@ } }, "node_modules/@jest/types": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", - "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.3.0.tgz", + "integrity": "sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==", "dev": true, "license": "MIT", "dependencies": { @@ -2493,6 +2520,84 @@ "node": ">=16" } }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", + "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", + "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", + "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", + "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", + "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", + "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -2607,23 +2712,83 @@ } }, "node_modules/@prisma/adapter-pg": { - "version": "7.4.2", - "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.4.2.tgz", - "integrity": "sha512-oUo2Zhe9Tf6YwVL8kLPuOLTK1Z2pwi/Ua77t2PuGyBan2w7shRKqHvYK+3XXmRH9RWhPJ4SMtHZKpNo6Ax/4bQ==", + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.5.0.tgz", + "integrity": "sha512-EJx7OLULahcC3IjJgdx2qRDNCT+ToY2v66UkeETMCLhNOTgqVzRzYvOEphY7Zp0eHyzfkC33Edd/qqeadf9R4A==", "license": "Apache-2.0", "dependencies": { - "@prisma/driver-adapter-utils": "7.4.2", + "@prisma/driver-adapter-utils": "7.5.0", + "@types/pg": "8.11.11", "pg": "^8.16.3", "postgres-array": "3.0.4" } }, + "node_modules/@prisma/adapter-pg/node_modules/@types/pg": { + "version": "8.11.11", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.11.11.tgz", + "integrity": "sha512-kGT1qKM8wJQ5qlawUrEkXgvMSXoV213KfMGXcwfDwUIfUHXqXYXOfS1nE1LINRJVVVx5wCm70XnFlMHaIcQAfw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^4.0.1" + } + }, + "node_modules/@prisma/adapter-pg/node_modules/pg-types": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-4.1.0.tgz", + "integrity": "sha512-o2XFanIMy/3+mThw69O8d4n1E5zsLhdO+OPqswezu7Z5ekP4hYDqlDjlmOpYMbzY2Br0ufCwJLdDIXeNVwcWFg==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "pg-numeric": "1.0.2", + "postgres-array": "~3.0.1", + "postgres-bytea": "~3.0.0", + "postgres-date": "~2.1.0", + "postgres-interval": "^3.0.0", + "postgres-range": "^1.1.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@prisma/adapter-pg/node_modules/postgres-bytea": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-3.0.0.tgz", + "integrity": "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==", + "license": "MIT", + "dependencies": { + "obuf": "~1.1.2" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@prisma/adapter-pg/node_modules/postgres-date": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-2.1.0.tgz", + "integrity": "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/@prisma/adapter-pg/node_modules/postgres-interval": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-3.0.0.tgz", + "integrity": "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/@prisma/client": { - "version": "7.4.2", - "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.4.2.tgz", - "integrity": "sha512-ts2mu+cQHriAhSxngO3StcYubBGTWDtu/4juZhXCUKOwgh26l+s4KD3vT2kMUzFyrYnll9u/3qWrtzRv9CGWzA==", + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.5.0.tgz", + "integrity": "sha512-h4hF9ctp+kSRs7ENHGsFQmHAgHcfkOCxbYt6Ti9Xi8x7D+kP4tTi9x51UKmiTH/OqdyJAO+8V+r+JA5AWdav7w==", "license": "Apache-2.0", "dependencies": { - "@prisma/client-runtime-utils": "7.4.2" + "@prisma/client-runtime-utils": "7.5.0" }, "engines": { "node": "^20.19 || ^22.12 || >=24.0" @@ -2642,15 +2807,15 @@ } }, "node_modules/@prisma/client-runtime-utils": { - "version": "7.4.2", - "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.4.2.tgz", - "integrity": "sha512-cID+rzOEb38VyMsx5LwJMEY4NGIrWCNpKu/0ImbeooQ2Px7TI+kOt7cm0NelxUzF2V41UVVXAmYjANZQtCu1/Q==", + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.5.0.tgz", + "integrity": "sha512-KnJ2b4Si/pcWEtK68uM+h0h1oh80CZt2suhLTVuLaSKg4n58Q9jBF/A42Kw6Ma+aThy1yAhfDeTC0JvEmeZnFQ==", "license": "Apache-2.0" }, "node_modules/@prisma/config": { - "version": "7.4.2", - "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.4.2.tgz", - "integrity": "sha512-CftBjWxav99lzY1Z4oDgomdb1gh9BJFAOmWF6P2v1xRfXqQb56DfBub+QKcERRdNoAzCb3HXy3Zii8Vb4AsXhg==", + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.5.0.tgz", + "integrity": "sha512-1J/9YEX7A889xM46PYg9e8VAuSL1IUmXJW3tEhMv7XQHDWlfC9YSkIw9sTYRaq5GswGlxZ+GnnyiNsUZ9JJhSQ==", "license": "Apache-2.0", "dependencies": { "c12": "3.1.0", @@ -2660,9 +2825,9 @@ } }, "node_modules/@prisma/debug": { - "version": "7.4.2", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.4.2.tgz", - "integrity": "sha512-aP7qzu+g/JnbF6U69LMwHoUkELiserKmWsE2shYuEpNUJ4GrtxBCvZwCyCBHFSH2kLTF2l1goBlBh4wuvRq62w==", + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.5.0.tgz", + "integrity": "sha512-163+nffny0JoPEkDhfNco0vcuT3ymIJc9+WX7MHSQhfkeKUmKe9/wqvGk5SjppT93DtBjVwr5HPJYlXbzm6qtg==", "license": "Apache-2.0" }, "node_modules/@prisma/dev": { @@ -2691,60 +2856,60 @@ } }, "node_modules/@prisma/driver-adapter-utils": { - "version": "7.4.2", - "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.4.2.tgz", - "integrity": "sha512-REdjFpT/ye9KdDs+CXAXPIbMQkVLhne9G5Pe97sNY4Ovx4r2DAbWM9hOFvvB1Oq8H8bOCdu0Ri3AoGALquQqVw==", + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.5.0.tgz", + "integrity": "sha512-B79N/amgV677mFesFDBAdrW0OIaqawap9E0sjgLBtzIz2R3hIMS1QB8mLZuUEiS4q5Y8Oh3I25Kw4SLxMypk9Q==", "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "7.4.2" + "@prisma/debug": "7.5.0" } }, "node_modules/@prisma/engines": { - "version": "7.4.2", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.4.2.tgz", - "integrity": "sha512-B+ZZhI4rXlzjVqRw/93AothEKOU5/x4oVyJFGo9RpHPnBwaPwk4Pi0Q4iGXipKxeXPs/dqljgNBjK0m8nocOJA==", + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.5.0.tgz", + "integrity": "sha512-ondGRhzoaVpRWvFaQ5wH5zS1BIbhzbKqczKjCn6j3L0Zfe/LInjcEg8+xtB49AuZBX30qyx1ZtGoootUohz2pw==", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "7.4.2", - "@prisma/engines-version": "7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919", - "@prisma/fetch-engine": "7.4.2", - "@prisma/get-platform": "7.4.2" + "@prisma/debug": "7.5.0", + "@prisma/engines-version": "7.5.0-15.280c870be64f457428992c43c1f6d557fab6e29e", + "@prisma/fetch-engine": "7.5.0", + "@prisma/get-platform": "7.5.0" } }, "node_modules/@prisma/engines-version": { - "version": "7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919", - "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919.tgz", - "integrity": "sha512-5FIKY3KoYQlBuZC2yc16EXfVRQ8HY+fLqgxkYfWCtKhRb3ajCRzP/rPeoSx11+NueJDANdh4hjY36mdmrTcGSg==", + "version": "7.5.0-15.280c870be64f457428992c43c1f6d557fab6e29e", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.5.0-15.280c870be64f457428992c43c1f6d557fab6e29e.tgz", + "integrity": "sha512-E+iRV/vbJLl8iGjVr6g/TEWokA+gjkV/doZkaQN1i/ULVdDwGnPJDfLUIFGS3BVwlG/m6L8T4x1x5isl8hGMxA==", "license": "Apache-2.0" }, "node_modules/@prisma/engines/node_modules/@prisma/get-platform": { - "version": "7.4.2", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.4.2.tgz", - "integrity": "sha512-UTnChXRwiauzl/8wT4hhe7Xmixja9WE28oCnGpBtRejaHhvekx5kudr3R4Y9mLSA0kqGnAMeyTiKwDVMjaEVsw==", + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.5.0.tgz", + "integrity": "sha512-7I+2y1nu/gkEKSiHHbcZ1HPe/euGdEqJZxEEMT0246q4De1+hla0ZzlTgvaT9dHcVCgLSuCG8v39db5qUUWNgw==", "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "7.4.2" + "@prisma/debug": "7.5.0" } }, "node_modules/@prisma/fetch-engine": { - "version": "7.4.2", - "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.4.2.tgz", - "integrity": "sha512-f/c/MwYpdJO7taLETU8rahEstLeXfYgQGlz5fycG7Fbmva3iPdzGmjiSWHeSWIgNnlXnelUdCJqyZnFocurZuA==", + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.5.0.tgz", + "integrity": "sha512-kZCl2FV54qnyrVdnII8MI6qvt7HfU6Cbiz8dZ8PXz4f4lbSw45jEB9/gEMK2SGdiNhBKyk/Wv95uthoLhGMLYA==", "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "7.4.2", - "@prisma/engines-version": "7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919", - "@prisma/get-platform": "7.4.2" + "@prisma/debug": "7.5.0", + "@prisma/engines-version": "7.5.0-15.280c870be64f457428992c43c1f6d557fab6e29e", + "@prisma/get-platform": "7.5.0" } }, "node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": { - "version": "7.4.2", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.4.2.tgz", - "integrity": "sha512-UTnChXRwiauzl/8wT4hhe7Xmixja9WE28oCnGpBtRejaHhvekx5kudr3R4Y9mLSA0kqGnAMeyTiKwDVMjaEVsw==", + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.5.0.tgz", + "integrity": "sha512-7I+2y1nu/gkEKSiHHbcZ1HPe/euGdEqJZxEEMT0246q4De1+hla0ZzlTgvaT9dHcVCgLSuCG8v39db5qUUWNgw==", "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "7.4.2" + "@prisma/debug": "7.5.0" } }, "node_modules/@prisma/get-platform": { @@ -2769,10 +2934,14 @@ "license": "Apache-2.0" }, "node_modules/@prisma/studio-core": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.13.1.tgz", - "integrity": "sha512-agdqaPEePRHcQ7CexEfkX1RvSH9uWDb6pXrZnhCRykhDFAV0/0P3d07WtfiY8hZWb7oRU4v+NkT4cGFHkQJIPg==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.21.1.tgz", + "integrity": "sha512-bOGqG/eMQtKC0XVvcVLRmhWWzm/I+0QUWqAEhEBtetpuS3k3V4IWqKGUONkAIT223DNXJMxMtZp36b1FmcdPeg==", "license": "Apache-2.0", + "engines": { + "node": "^20.19 || ^22.12 || ^24.0", + "pnpm": "8" + }, "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", @@ -2786,6 +2955,22 @@ "hasInstallScript": true, "license": "Apache-2.0" }, + "node_modules/@simple-libs/child-process-utils": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@simple-libs/child-process-utils/-/child-process-utils-1.0.2.tgz", + "integrity": "sha512-/4R8QKnd/8agJynkNdJmNw2MBxuFTRcNFnE5Sg/G+jkSsV8/UBgULMzhizWWW42p8L5H7flImV2ATi79Ove2Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@simple-libs/stream-utils": "^1.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://ko-fi.com/dangreen" + } + }, "node_modules/@simple-libs/stream-utils": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@simple-libs/stream-utils/-/stream-utils-1.2.0.tgz", @@ -2817,15 +3002,21 @@ } }, "node_modules/@sinonjs/fake-timers": { - "version": "13.0.5", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", - "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.1.1.tgz", + "integrity": "sha512-cO5W33JgAPbOh07tvZjUOJ7oWhtaqGHiZw+11DPbyqh2kHTBc3eF/CjJDeQ4205RLQsX6rxCuYOroFQwl7JDRw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { "@sinonjs/commons": "^3.0.1" } }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -3074,7 +3265,6 @@ "version": "22.19.15", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", - "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -3217,17 +3407,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", - "integrity": "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.0.tgz", + "integrity": "sha512-qeu4rTHR3/IaFORbD16gmjq9+rEs9fGKdX0kF6BKSfi+gCuG3RCKLlSBYzn/bGsY9Tj7KE/DAQStbp8AHJGHEQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.56.1", - "@typescript-eslint/type-utils": "8.56.1", - "@typescript-eslint/utils": "8.56.1", - "@typescript-eslint/visitor-keys": "8.56.1", + "@typescript-eslint/scope-manager": "8.57.0", + "@typescript-eslint/type-utils": "8.57.0", + "@typescript-eslint/utils": "8.57.0", + "@typescript-eslint/visitor-keys": "8.57.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" @@ -3240,7 +3430,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.56.1", + "@typescript-eslint/parser": "^8.57.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } @@ -3256,17 +3446,17 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz", - "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.0.tgz", + "integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.56.1", - "@typescript-eslint/types": "8.56.1", - "@typescript-eslint/typescript-estree": "8.56.1", - "@typescript-eslint/visitor-keys": "8.56.1", + "@typescript-eslint/scope-manager": "8.57.0", + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/typescript-estree": "8.57.0", + "@typescript-eslint/visitor-keys": "8.57.0", "debug": "^4.4.3" }, "engines": { @@ -3282,14 +3472,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz", - "integrity": "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.0.tgz", + "integrity": "sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.56.1", - "@typescript-eslint/types": "^8.56.1", + "@typescript-eslint/tsconfig-utils": "^8.57.0", + "@typescript-eslint/types": "^8.57.0", "debug": "^4.4.3" }, "engines": { @@ -3304,14 +3494,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.1.tgz", - "integrity": "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.0.tgz", + "integrity": "sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.56.1", - "@typescript-eslint/visitor-keys": "8.56.1" + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/visitor-keys": "8.57.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3322,9 +3512,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.1.tgz", - "integrity": "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.0.tgz", + "integrity": "sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA==", "dev": true, "license": "MIT", "engines": { @@ -3339,15 +3529,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.1.tgz", - "integrity": "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.0.tgz", + "integrity": "sha512-yjgh7gmDcJ1+TcEg8x3uWQmn8ifvSupnPfjP21twPKrDP/pTHlEQgmKcitzF/rzPSmv7QjJ90vRpN4U+zoUjwQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.56.1", - "@typescript-eslint/typescript-estree": "8.56.1", - "@typescript-eslint/utils": "8.56.1", + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/typescript-estree": "8.57.0", + "@typescript-eslint/utils": "8.57.0", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, @@ -3364,9 +3554,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.1.tgz", - "integrity": "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.0.tgz", + "integrity": "sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg==", "dev": true, "license": "MIT", "engines": { @@ -3378,16 +3568,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.1.tgz", - "integrity": "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.0.tgz", + "integrity": "sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.56.1", - "@typescript-eslint/tsconfig-utils": "8.56.1", - "@typescript-eslint/types": "8.56.1", - "@typescript-eslint/visitor-keys": "8.56.1", + "@typescript-eslint/project-service": "8.57.0", + "@typescript-eslint/tsconfig-utils": "8.57.0", + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/visitor-keys": "8.57.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -3445,16 +3635,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.1.tgz", - "integrity": "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.0.tgz", + "integrity": "sha512-5iIHvpD3CZe06riAsbNxxreP+MuYgVUsV0n4bwLH//VJmgtt54sQeY2GszntJ4BjYCpMzrfVh2SBnUQTtys2lQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.56.1", - "@typescript-eslint/types": "8.56.1", - "@typescript-eslint/typescript-estree": "8.56.1" + "@typescript-eslint/scope-manager": "8.57.0", + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/typescript-estree": "8.57.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3469,13 +3659,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.1.tgz", - "integrity": "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.0.tgz", + "integrity": "sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/types": "8.57.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -3895,6 +4085,19 @@ "node": ">= 8" } }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/arg": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", @@ -3990,16 +4193,16 @@ } }, "node_modules/babel-jest": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz", - "integrity": "sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.3.0.tgz", + "integrity": "sha512-gRpauEU2KRrCox5Z296aeVHR4jQ98BCnu0IO332D/xpHNOsIH/bgSRk9k6GbKIbBw8vFeN6ctuu6tV8WOyVfYQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/transform": "30.2.0", + "@jest/transform": "30.3.0", "@types/babel__core": "^7.20.5", "babel-plugin-istanbul": "^7.0.1", - "babel-preset-jest": "30.2.0", + "babel-preset-jest": "30.3.0", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "slash": "^3.0.0" @@ -4108,9 +4311,9 @@ } }, "node_modules/babel-plugin-jest-hoist": { - "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==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.3.0.tgz", + "integrity": "sha512-+TRkByhsws6sfPjVaitzadk1I0F5sPvOVUH5tyTSzhePpsGIVrdeunHSw/C36QeocS95OOk8lunc4rlu5Anwsg==", "dev": true, "license": "MIT", "dependencies": { @@ -4148,13 +4351,13 @@ } }, "node_modules/babel-preset-jest": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.2.0.tgz", - "integrity": "sha512-US4Z3NOieAQumwFnYdUWKvUKh8+YSnS/gB3t6YBiz0bskpu7Pine8pPCheNxlPEW4wnUkma2a94YuW2q3guvCQ==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.3.0.tgz", + "integrity": "sha512-6ZcUbWHC+dMz2vfzdNwi87Z1gQsLNK2uLuK1Q89R11xdvejcivlYYwDlEv0FHX3VwEXpbBQ9uufB/MUNpZGfhQ==", "dev": true, "license": "MIT", "dependencies": { - "babel-plugin-jest-hoist": "30.2.0", + "babel-plugin-jest-hoist": "30.3.0", "babel-preset-current-node-syntax": "^1.2.0" }, "engines": { @@ -4192,9 +4395,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", - "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "version": "2.10.7", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.7.tgz", + "integrity": "sha512-1ghYO3HnxGec0TCGBXiDLVns4eCSx4zJpxnHrlqFQajmhfKMQBzUGDdkMK7fUW7PTHTeLf+j87aTuKuuwWzMGw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -4387,6 +4590,21 @@ "dev": true, "license": "MIT" }, + "node_modules/bullmq": { + "version": "5.71.0", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.71.0.tgz", + "integrity": "sha512-aeNWh4drsafSKnAJeiNH/nZP/5O8ZdtdMbnOPZmpjXj7NZUP5YC901U3bIH41iZValm7d1i3c34ojv7q31m30w==", + "license": "MIT", + "dependencies": { + "cron-parser": "4.9.0", + "ioredis": "5.9.3", + "msgpackr": "1.11.5", + "node-abort-controller": "3.1.1", + "semver": "7.7.4", + "tslib": "2.8.1", + "uuid": "11.1.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -4518,9 +4736,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001777", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz", - "integrity": "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==", + "version": "1.0.30001778", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001778.tgz", + "integrity": "sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg==", "dev": true, "funding": [ { @@ -5243,6 +5461,18 @@ "dev": true, "license": "MIT" }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "license": "MIT", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/cross-env": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", @@ -5301,19 +5531,6 @@ "@commitlint/load": ">6.1.1" } }, - "node_modules/dargs": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/dargs/-/dargs-8.1.0.tgz", - "integrity": "sha512-wAV9QHOsNbwnWdNW2FYvE1P56wtgSbM+3SZcdGiWQILwVjACCXDCI3Ai8QlCjMDB8YK5zySiXZYBiwGmNY3lnw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/dateformat": { "version": "4.6.3", "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", @@ -5456,6 +5673,16 @@ "node": ">=8" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -5584,9 +5811,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.307", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz", - "integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==", + "version": "1.5.313", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz", + "integrity": "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA==", "dev": true, "license": "ISC" }, @@ -6067,18 +6294,18 @@ } }, "node_modules/expect": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-30.2.0.tgz", - "integrity": "sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.3.0.tgz", + "integrity": "sha512-1zQrciTiQfRdo7qJM1uG4navm8DayFa2TgCSRlzUyNkhcJ6XUZF3hjnpkyr3VhAqPH7i/9GkG7Tv5abz6fqz0Q==", "dev": true, "license": "MIT", "dependencies": { - "@jest/expect-utils": "30.2.0", + "@jest/expect-utils": "30.3.0", "@jest/get-type": "30.1.0", - "jest-matcher-utils": "30.2.0", - "jest-message-util": "30.2.0", - "jest-mock": "30.2.0", - "jest-util": "30.2.0" + "jest-matcher-utils": "30.3.0", + "jest-message-util": "30.3.0", + "jest-mock": "30.3.0", + "jest-util": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -6274,6 +6501,12 @@ "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fast-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", @@ -6311,6 +6544,24 @@ "bser": "2.1.1" } }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/figures": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", @@ -6452,9 +6703,9 @@ } }, "node_modules/flatted": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.4.tgz", - "integrity": "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz", + "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==", "dev": true, "license": "ISC" }, @@ -6710,35 +6961,20 @@ } }, "node_modules/git-raw-commits": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-4.0.0.tgz", - "integrity": "sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ==", - "deprecated": "This package is no longer maintained. For the JavaScript API, please use @conventional-changelog/git-client instead.", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/git-raw-commits/-/git-raw-commits-5.0.1.tgz", + "integrity": "sha512-Y+csSm2GD/PCSh6Isd/WiMjNAydu0VBiG9J7EdQsNA5P9uXvLayqjmTsNlK5Gs9IhblFZqOU0yid5Il5JPoLiQ==", "dev": true, "license": "MIT", "dependencies": { - "dargs": "^8.0.0", - "meow": "^12.0.1", - "split2": "^4.0.0" + "@conventional-changelog/git-client": "^2.6.0", + "meow": "^13.0.0" }, "bin": { - "git-raw-commits": "cli.mjs" + "git-raw-commits": "src/cli.js" }, "engines": { - "node": ">=16" - } - }, - "node_modules/git-raw-commits/node_modules/meow": { - "version": "12.1.1", - "resolved": "https://registry.npmjs.org/meow/-/meow-12.1.1.tgz", - "integrity": "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16.10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=18" } }, "node_modules/glob": { @@ -7552,17 +7788,17 @@ } }, "node_modules/jest": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest/-/jest-30.2.0.tgz", - "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.3.0.tgz", + "integrity": "sha512-AkXIIFcaazymvey2i/+F94XRnM6TsVLZDhBMLsd1Sf/W0wzsvvpjeyUrCZD6HGG4SDYPgDJDBKeiJTBb10WzMg==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@jest/core": "30.2.0", - "@jest/types": "30.2.0", + "@jest/core": "30.3.0", + "@jest/types": "30.3.0", "import-local": "^3.2.0", - "jest-cli": "30.2.0" + "jest-cli": "30.3.0" }, "bin": { "jest": "bin/jest.js" @@ -7580,14 +7816,14 @@ } }, "node_modules/jest-changed-files": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.2.0.tgz", - "integrity": "sha512-L8lR1ChrRnSdfeOvTrwZMlnWV8G/LLjQ0nG9MBclwWZidA2N5FviRki0Bvh20WRMOX31/JYvzdqTJrk5oBdydQ==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.3.0.tgz", + "integrity": "sha512-B/7Cny6cV5At6M25EWDgf9S617lHivamL8vl6KEpJqkStauzcG4e+WPfDgMMF+H4FVH4A2PLRyvgDJan4441QA==", "dev": true, "license": "MIT", "dependencies": { "execa": "^5.1.1", - "jest-util": "30.2.0", + "jest-util": "30.3.0", "p-limit": "^3.1.0" }, "engines": { @@ -7595,29 +7831,29 @@ } }, "node_modules/jest-circus": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.2.0.tgz", - "integrity": "sha512-Fh0096NC3ZkFx05EP2OXCxJAREVxj1BcW/i6EWqqymcgYKWjyyDpral3fMxVcHXg6oZM7iULer9wGRFvfpl+Tg==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.3.0.tgz", + "integrity": "sha512-PyXq5szeSfR/4f1lYqCmmQjh0vqDkURUYi9N6whnHjlRz4IUQfMcXkGLeEoiJtxtyPqgUaUUfyQlApXWBSN1RA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.2.0", - "@jest/expect": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/types": "30.2.0", + "@jest/environment": "30.3.0", + "@jest/expect": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", "co": "^4.6.0", "dedent": "^1.6.0", "is-generator-fn": "^2.1.0", - "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", + "jest-each": "30.3.0", + "jest-matcher-utils": "30.3.0", + "jest-message-util": "30.3.0", + "jest-runtime": "30.3.0", + "jest-snapshot": "30.3.0", + "jest-util": "30.3.0", "p-limit": "^3.1.0", - "pretty-format": "30.2.0", + "pretty-format": "30.3.0", "pure-rand": "^7.0.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" @@ -7718,21 +7954,21 @@ } }, "node_modules/jest-cli": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.2.0.tgz", - "integrity": "sha512-Os9ukIvADX/A9sLt6Zse3+nmHtHaE6hqOsjQtNiugFTbKRHYIYtZXNGNK9NChseXy7djFPjndX1tL0sCTlfpAA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.3.0.tgz", + "integrity": "sha512-l6Tqx+j1fDXJEW5bqYykDQQ7mQg+9mhWXtnj+tQZrTWYHyHoi6Be8HPumDSA+UiX2/2buEgjA58iJzdj146uCw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/core": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/types": "30.2.0", + "@jest/core": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/types": "30.3.0", "chalk": "^4.1.2", "exit-x": "^0.2.2", "import-local": "^3.2.0", - "jest-config": "30.2.0", - "jest-util": "30.2.0", - "jest-validate": "30.2.0", + "jest-config": "30.3.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0", "yargs": "^17.7.2" }, "bin": { @@ -7827,34 +8063,33 @@ } }, "node_modules/jest-config": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.2.0.tgz", - "integrity": "sha512-g4WkyzFQVWHtu6uqGmQR4CQxz/CH3yDSlhzXMWzNjDx843gYjReZnMRanjRCq5XZFuQrGDxgUaiYWE8BRfVckA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.3.0.tgz", + "integrity": "sha512-WPMAkMAtNDY9P/oKObtsRG/6KTrhtgPJoBTmk20uDn4Uy6/3EJnnaZJre/FMT1KVRx8cve1r7/FlMIOfRVWL4w==", "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.2.0", - "@jest/types": "30.2.0", - "babel-jest": "30.2.0", + "@jest/test-sequencer": "30.3.0", + "@jest/types": "30.3.0", + "babel-jest": "30.3.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "deepmerge": "^4.3.1", - "glob": "^10.3.10", + "glob": "^10.5.0", "graceful-fs": "^4.2.11", - "jest-circus": "30.2.0", + "jest-circus": "30.3.0", "jest-docblock": "30.2.0", - "jest-environment-node": "30.2.0", + "jest-environment-node": "30.3.0", "jest-regex-util": "30.0.1", - "jest-resolve": "30.2.0", - "jest-runner": "30.2.0", - "jest-util": "30.2.0", - "jest-validate": "30.2.0", - "micromatch": "^4.0.8", + "jest-resolve": "30.3.0", + "jest-runner": "30.3.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0", "parse-json": "^5.2.0", - "pretty-format": "30.2.0", + "pretty-format": "30.3.0", "slash": "^3.0.0", "strip-json-comments": "^3.1.1" }, @@ -8003,16 +8238,16 @@ } }, "node_modules/jest-diff": { - "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==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.3.0.tgz", + "integrity": "sha512-n3q4PDQjS4LrKxfWB3Z5KNk1XjXtZTBwQp71OP0Jo03Z6V60x++K5L8k6ZrW8MY8pOFylZvHM0zsjS1RqlHJZQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/diff-sequences": "30.0.1", + "@jest/diff-sequences": "30.3.0", "@jest/get-type": "30.1.0", "chalk": "^4.1.2", - "pretty-format": "30.2.0" + "pretty-format": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -8108,17 +8343,17 @@ } }, "node_modules/jest-each": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.2.0.tgz", - "integrity": "sha512-lpWlJlM7bCUf1mfmuqTA8+j2lNURW9eNafOy99knBM01i5CQeY5UH1vZjgT9071nDJac1M4XsbyI44oNOdhlDQ==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.3.0.tgz", + "integrity": "sha512-V8eMndg/aZ+3LnCJgSm13IxS5XSBM22QSZc9BtPK8Dek6pm+hfUNfwBdvsB3d342bo1q7wnSkC38zjX259qZNA==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "chalk": "^4.1.2", - "jest-util": "30.2.0", - "pretty-format": "30.2.0" + "jest-util": "30.3.0", + "pretty-format": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -8201,40 +8436,40 @@ } }, "node_modules/jest-environment-node": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.2.0.tgz", - "integrity": "sha512-ElU8v92QJ9UrYsKrxDIKCxu6PfNj4Hdcktcn0JX12zqNdqWHB0N+hwOnnBBXvjLd2vApZtuLUGs1QSY+MsXoNA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.3.0.tgz", + "integrity": "sha512-4i6HItw/JSiJVsC5q0hnKIe/hbYfZLVG9YJ/0pU9Hz2n/9qZe3Rhn5s5CUZA5ORZlcdT/vmAXRMyONXJwPrmYQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.2.0", - "@jest/fake-timers": "30.2.0", - "@jest/types": "30.2.0", + "@jest/environment": "30.3.0", + "@jest/fake-timers": "30.3.0", + "@jest/types": "30.3.0", "@types/node": "*", - "jest-mock": "30.2.0", - "jest-util": "30.2.0", - "jest-validate": "30.2.0" + "jest-mock": "30.3.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-haste-map": { - "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==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.3.0.tgz", + "integrity": "sha512-mMi2oqG4KRU0R9QEtscl87JzMXfUhbKaFqOxmjb2CKcbHcUGFrJCBWHmnTiUqi6JcnzoBlO4rWfpdl2k/RfLCA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.3.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.2.0", - "jest-worker": "30.2.0", - "micromatch": "^4.0.8", + "jest-util": "30.3.0", + "jest-worker": "30.3.0", + "picomatch": "^4.0.3", "walker": "^1.0.8" }, "engines": { @@ -8245,30 +8480,30 @@ } }, "node_modules/jest-leak-detector": { - "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==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.3.0.tgz", + "integrity": "sha512-cuKmUUGIjfXZAiGJ7TbEMx0bcqNdPPI6P1V+7aF+m/FUJqFDxkFR4JqkTu8ZOiU5AaX/x0hZ20KaaIPXQzbMGQ==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", - "pretty-format": "30.2.0" + "pretty-format": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-matcher-utils": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.2.0.tgz", - "integrity": "sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.3.0.tgz", + "integrity": "sha512-HEtc9uFQgaUHkC7nLSlQL3Tph4Pjxt/yiPvkIrrDCt9jhoLIgxaubo1G+CFOnmHYMxHwwdaSN7mkIFs6ZK8OhA==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", "chalk": "^4.1.2", - "jest-diff": "30.2.0", - "pretty-format": "30.2.0" + "jest-diff": "30.3.0", + "pretty-format": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -8351,19 +8586,19 @@ } }, "node_modules/jest-message-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", - "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.3.0.tgz", + "integrity": "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", - "micromatch": "^4.0.8", - "pretty-format": "30.2.0", + "picomatch": "^4.0.3", + "pretty-format": "30.3.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" }, @@ -8448,15 +8683,15 @@ } }, "node_modules/jest-mock": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.2.0.tgz", - "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.3.0.tgz", + "integrity": "sha512-OTzICK8CpE+t4ndhKrwlIdbM6Pn8j00lvmSmq5ejiO+KxukbLjgOflKWMn3KE34EZdQm5RqTuKj+5RIEniYhog==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/node": "*", - "jest-util": "30.2.0" + "jest-util": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -8491,18 +8726,18 @@ } }, "node_modules/jest-resolve": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.2.0.tgz", - "integrity": "sha512-TCrHSxPlx3tBY3hWNtRQKbtgLhsXa1WmbJEqBlTBrGafd5fiQFByy2GNCEoGR+Tns8d15GaL9cxEzKOO3GEb2A==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.3.0.tgz", + "integrity": "sha512-NRtTAHQlpd15F9rUR36jqwelbrDV/dY4vzNte3S2kxCKUJRYNd5/6nTSbYiak1VX5g8IoFF23Uj5TURkUW8O5g==", "dev": true, "license": "MIT", "dependencies": { "chalk": "^4.1.2", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.2.0", + "jest-haste-map": "30.3.0", "jest-pnp-resolver": "^1.2.3", - "jest-util": "30.2.0", - "jest-validate": "30.2.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0", "slash": "^3.0.0", "unrs-resolver": "^1.7.11" }, @@ -8511,14 +8746,14 @@ } }, "node_modules/jest-resolve-dependencies": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.2.0.tgz", - "integrity": "sha512-xTOIGug/0RmIe3mmCqCT95yO0vj6JURrn1TKWlNbhiAefJRWINNPgwVkrVgt/YaerPzY3iItufd80v3lOrFJ2w==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.3.0.tgz", + "integrity": "sha512-9ev8s3YN6Hsyz9LV75XUwkCVFlwPbaFn6Wp75qnI0wzAINYWY8Fb3+6y59Rwd3QaS3kKXffHXsZMziMavfz/nw==", "dev": true, "license": "MIT", "dependencies": { "jest-regex-util": "30.0.1", - "jest-snapshot": "30.2.0" + "jest-snapshot": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -8601,32 +8836,32 @@ } }, "node_modules/jest-runner": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.2.0.tgz", - "integrity": "sha512-PqvZ2B2XEyPEbclp+gV6KO/F1FIFSbIwewRgmROCMBo/aZ6J1w8Qypoj2pEOcg3G2HzLlaP6VUtvwCI8dM3oqQ==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.3.0.tgz", + "integrity": "sha512-gDv6C9LGKWDPLia9TSzZwf4h3kMQCqyTpq+95PODnTRDO0g9os48XIYYkS6D236vjpBir2fF63YmJFtqkS5Duw==", "dev": true, "license": "MIT", "dependencies": { - "@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", + "@jest/console": "30.3.0", + "@jest/environment": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", "emittery": "^0.13.1", "exit-x": "^0.2.2", "graceful-fs": "^4.2.11", "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", + "jest-environment-node": "30.3.0", + "jest-haste-map": "30.3.0", + "jest-leak-detector": "30.3.0", + "jest-message-util": "30.3.0", + "jest-resolve": "30.3.0", + "jest-runtime": "30.3.0", + "jest-util": "30.3.0", + "jest-watcher": "30.3.0", + "jest-worker": "30.3.0", "p-limit": "^3.1.0", "source-map-support": "0.5.13" }, @@ -8711,32 +8946,32 @@ } }, "node_modules/jest-runtime": { - "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==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.3.0.tgz", + "integrity": "sha512-CgC+hIBJbuh78HEffkhNKcbXAytQViplcl8xupqeIWyKQF50kCQA8J7GeJCkjisC6hpnC9Muf8jV5RdtdFbGng==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.2.0", - "@jest/fake-timers": "30.2.0", - "@jest/globals": "30.2.0", + "@jest/environment": "30.3.0", + "@jest/fake-timers": "30.3.0", + "@jest/globals": "30.3.0", "@jest/source-map": "30.0.1", - "@jest/test-result": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", + "@jest/test-result": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", "cjs-module-lexer": "^2.1.0", "collect-v8-coverage": "^1.0.2", - "glob": "^10.3.10", + "glob": "^10.5.0", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.2.0", - "jest-message-util": "30.2.0", - "jest-mock": "30.2.0", + "jest-haste-map": "30.3.0", + "jest-message-util": "30.3.0", + "jest-mock": "30.3.0", "jest-regex-util": "30.0.1", - "jest-resolve": "30.2.0", - "jest-snapshot": "30.2.0", - "jest-util": "30.2.0", + "jest-resolve": "30.3.0", + "jest-snapshot": "30.3.0", + "jest-util": "30.3.0", "slash": "^3.0.0", "strip-bom": "^4.0.0" }, @@ -8869,9 +9104,9 @@ } }, "node_modules/jest-snapshot": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.2.0.tgz", - "integrity": "sha512-5WEtTy2jXPFypadKNpbNkZ72puZCa6UjSr/7djeecHWOu7iYhSXSnHScT8wBz3Rn8Ena5d5RYRcsyKIeqG1IyA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.3.0.tgz", + "integrity": "sha512-f14c7atpb4O2DeNhwcvS810Y63wEn8O1HqK/luJ4F6M4NjvxmAKQwBUWjbExUtMxWJQ0wVgmCKymeJK6NZMnfQ==", "dev": true, "license": "MIT", "dependencies": { @@ -8880,20 +9115,20 @@ "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/types": "^7.27.3", - "@jest/expect-utils": "30.2.0", + "@jest/expect-utils": "30.3.0", "@jest/get-type": "30.1.0", - "@jest/snapshot-utils": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", + "@jest/snapshot-utils": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", "babel-preset-current-node-syntax": "^1.2.0", "chalk": "^4.1.2", - "expect": "30.2.0", + "expect": "30.3.0", "graceful-fs": "^4.2.11", - "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", + "jest-diff": "30.3.0", + "jest-matcher-utils": "30.3.0", + "jest-message-util": "30.3.0", + "jest-util": "30.3.0", + "pretty-format": "30.3.0", "semver": "^7.7.2", "synckit": "^0.11.8" }, @@ -8978,18 +9213,18 @@ } }, "node_modules/jest-util": { - "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==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", + "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", - "picomatch": "^4.0.2" + "picomatch": "^4.0.3" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -9058,19 +9293,6 @@ "node": ">=8" } }, - "node_modules/jest-util/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/jest-util/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -9085,18 +9307,18 @@ } }, "node_modules/jest-validate": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.2.0.tgz", - "integrity": "sha512-FBGWi7dP2hpdi8nBoWxSsLvBFewKAg0+uSQwBaof4Y4DPgBabXgpSYC5/lR7VmnIlSpASmCi/ntRWPbv7089Pw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.3.0.tgz", + "integrity": "sha512-I/xzC8h5G+SHCb2P2gWkJYrNiTbeL47KvKeW5EzplkyxzBRBw1ssSHlI/jXec0ukH2q7x2zAWQm7015iusg62Q==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "camelcase": "^6.3.0", "chalk": "^4.1.2", "leven": "^3.1.0", - "pretty-format": "30.2.0" + "pretty-format": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -9192,19 +9414,19 @@ } }, "node_modules/jest-watcher": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.2.0.tgz", - "integrity": "sha512-PYxa28dxJ9g777pGm/7PrbnMeA0Jr7osHP9bS7eJy9DuAjMgdGtxgf0uKMyoIsTWAkIbUW5hSDdJ3urmgXBqxg==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.3.0.tgz", + "integrity": "sha512-PJ1d9ThtTR8aMiBWUdcownq9mDdLXsQzJayTk4kmaBRHKvwNQn+ANveuhEBUyNI2hR1TVhvQ8D5kHubbzBHR/w==", "dev": true, "license": "MIT", "dependencies": { - "@jest/test-result": "30.2.0", - "@jest/types": "30.2.0", + "@jest/test-result": "30.3.0", + "@jest/types": "30.3.0", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", "emittery": "^0.13.1", - "jest-util": "30.2.0", + "jest-util": "30.3.0", "string-length": "^4.0.2" }, "engines": { @@ -9288,15 +9510,15 @@ } }, "node_modules/jest-worker": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.2.0.tgz", - "integrity": "sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.3.0.tgz", + "integrity": "sha512-DrCKkaQwHexjRUFTmPzs7sHQe0TSj9nvDALKGdwmK5mW9v7j90BudWirKAJHt3QQ9Dhrg1F7DogPzhChppkJpQ==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", "@ungap/structured-clone": "^1.3.0", - "jest-util": "30.2.0", + "jest-util": "30.3.0", "merge-stream": "^2.0.0", "supports-color": "^8.1.1" }, @@ -9813,6 +10035,15 @@ "url": "https://github.com/sponsors/wellwelwel" } }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/make-dir": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -9933,6 +10164,19 @@ "node": ">=8.6" } }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/mime": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", @@ -10013,6 +10257,37 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/msgpackr": { + "version": "1.11.5", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.5.tgz", + "integrity": "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", + "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" + } + }, "node_modules/mute-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-1.0.0.tgz", @@ -10124,6 +10399,12 @@ "dev": true, "license": "MIT" }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", + "license": "MIT" + }, "node_modules/node-addon-api": { "version": "8.6.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.6.0.tgz", @@ -10150,6 +10431,21 @@ "node-gyp-build-test": "build-test.js" } }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -10299,6 +10595,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obuf": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", + "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", + "license": "MIT" + }, "node_modules/ohash": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", @@ -10367,21 +10669,6 @@ "yaml": "^2.8.0" } }, - "node_modules/openapi3-ts/node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -10740,6 +11027,15 @@ "node": ">=4.0.0" } }, + "node_modules/pg-numeric": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pg-numeric/-/pg-numeric-1.0.2.tgz", + "integrity": "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==", + "license": "ISC", + "engines": { + "node": ">=4" + } + }, "node_modules/pg-pool": { "version": "3.13.0", "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", @@ -10797,13 +11093,13 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" @@ -10997,6 +11293,12 @@ "node": ">=12" } }, + "node_modules/postal-mime": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.3.tgz", + "integrity": "sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw==", + "license": "MIT-0" + }, "node_modules/postgres": { "version": "3.4.7", "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", @@ -11049,6 +11351,12 @@ "node": ">=0.10.0" } }, + "node_modules/postgres-range": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/postgres-range/-/postgres-range-1.1.4.tgz", + "integrity": "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==", + "license": "MIT" + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -11060,9 +11368,9 @@ } }, "node_modules/pretty-format": { - "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==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", + "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", "dev": true, "license": "MIT", "dependencies": { @@ -11088,17 +11396,17 @@ } }, "node_modules/prisma": { - "version": "7.4.2", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.4.2.tgz", - "integrity": "sha512-2bP8Ruww3Q95Z2eH4Yqh4KAENRsj/SxbdknIVBfd6DmjPwmpsC4OVFMLOeHt6tM3Amh8ebjvstrUz3V/hOe1dA==", + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.5.0.tgz", + "integrity": "sha512-n30qZpWehaYQzigLjmuPisyEsvOzHt7bZeRyg8gZ5DvJo9FGjD+gNaY59Ns3hlLD5/jZH5GBeftIss0jDbUoLg==", "hasInstallScript": true, "license": "Apache-2.0", "peer": true, "dependencies": { - "@prisma/config": "7.4.2", + "@prisma/config": "7.5.0", "@prisma/dev": "0.20.0", - "@prisma/engines": "7.4.2", - "@prisma/studio-core": "0.13.1", + "@prisma/engines": "7.5.0", + "@prisma/studio-core": "0.21.1", "mysql2": "3.15.3", "postgres": "3.4.7" }, @@ -11349,6 +11657,19 @@ "node": ">=8.10.0" } }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/real-require": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", @@ -11414,6 +11735,27 @@ "node": ">=0.10.0" } }, + "node_modules/resend": { + "version": "6.9.3", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.9.3.tgz", + "integrity": "sha512-GRXjH9XZBJA+daH7bBVDuTShr22iWCxXA8P7t495G4dM/RC+d+3gHBK/6bz9K6Vpcq11zRQKmD+B+jECwQlyGQ==", + "license": "MIT", + "dependencies": { + "postal-mime": "2.7.3", + "svix": "1.84.1" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@react-email/render": "*" + }, + "peerDependenciesMeta": { + "@react-email/render": { + "optional": true + } + } + }, "node_modules/resolve-cwd": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", @@ -11879,6 +12221,16 @@ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", "license": "MIT" }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -12081,6 +12433,29 @@ "node": ">=4" } }, + "node_modules/svix": { + "version": "1.84.1", + "resolved": "https://registry.npmjs.org/svix/-/svix-1.84.1.tgz", + "integrity": "sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ==", + "license": "MIT", + "dependencies": { + "standardwebhooks": "1.0.0", + "uuid": "^10.0.0" + } + }, + "node_modules/svix/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/swagger-jsdoc": { "version": "6.2.8", "resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz", @@ -12122,6 +12497,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/swagger-jsdoc/node_modules/yaml": { + "version": "2.0.0-1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz", + "integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, "node_modules/swagger-parser": { "version": "10.0.3", "resolved": "https://registry.npmjs.org/swagger-parser/-/swagger-parser-10.0.3.tgz", @@ -12209,9 +12593,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", - "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", "license": "MIT", "engines": { "node": ">=18" @@ -12234,38 +12618,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/tmp": { "version": "0.0.33", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", @@ -12503,7 +12855,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/type-check": { @@ -12571,16 +12922,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.56.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.1.tgz", - "integrity": "sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==", + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.0.tgz", + "integrity": "sha512-W8GcigEMEeB07xEZol8oJ26rigm3+bfPHxHvwbYUlu1fUDsGuQ7Hiskx5xGW/xM4USc9Ephe3jtv7ZYPQntHeA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.56.1", - "@typescript-eslint/parser": "8.56.1", - "@typescript-eslint/typescript-estree": "8.56.1", - "@typescript-eslint/utils": "8.56.1" + "@typescript-eslint/eslint-plugin": "8.57.0", + "@typescript-eslint/parser": "8.57.0", + "@typescript-eslint/typescript-estree": "8.57.0", + "@typescript-eslint/utils": "8.57.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -12619,7 +12970,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/universalify": { @@ -12733,6 +13083,19 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -13005,12 +13368,18 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.0.0-1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz", - "integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==", + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, "engines": { - "node": ">= 6" + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" } }, "node_modules/yargs": { diff --git a/package.json b/package.json index 6648964..1a1be7e 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "start": "node dist/index.js", "postinstall": "prisma generate", "db:migrate": "prisma migrate dev", - "db:push": "prisma db push", + "db:deploy": "prisma migrate deploy", "test": "jest --forceExit --detectOpenHandles", "test:coverage": "jest --forceExit --detectOpenHandles --coverage", "lint": "eslint .", @@ -30,6 +30,7 @@ "@prisma/adapter-pg": "^7.4.2", "@prisma/client": "^7.4.2", "argon2": "^0.44.0", + "bullmq": "^5.71.0", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.19.2", @@ -42,6 +43,7 @@ "pino-http": "^11.0.0", "pino-pretty": "^13.1.3", "prisma": "^7.4.2", + "resend": "^6.9.3", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1", "zod": "^4.3.6" @@ -74,5 +76,8 @@ "tsconfig-paths": "^4.2.0", "typescript": "~5.7.2", "typescript-eslint": "^8.24.1" + }, + "overrides": { + "ioredis": "^5.10.0" } } diff --git a/prisma/migrations/0_init/migration.sql b/prisma/migrations/0_init/migration.sql new file mode 100644 index 0000000..d8ddf7e --- /dev/null +++ b/prisma/migrations/0_init/migration.sql @@ -0,0 +1,35 @@ +-- CreateSchema +CREATE SCHEMA IF NOT EXISTS "public"; + +-- CreateTable +CREATE TABLE "users" ( + "id" UUID NOT NULL, + "email" TEXT NOT NULL, + "password" TEXT, + "name" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "users_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "accounts" ( + "id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "provider" TEXT NOT NULL, + "provider_account_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "accounts_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "accounts_provider_provider_account_id_key" ON "accounts"("provider", "provider_account_id"); + +-- AddForeignKey +ALTER TABLE "accounts" ADD CONSTRAINT "accounts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..99e4f20 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 81bb6d3..c32fee7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -16,9 +16,24 @@ model User { email String @unique password String? name String - provider String @default("local") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") + accounts Account[] + @@map("users") } + +model Account { + id String @id @default(uuid()) @db.Uuid + userId String @map("user_id") @db.Uuid + provider String + providerAccountId String @map("provider_account_id") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([provider, providerAccountId]) + @@map("accounts") +} diff --git a/src/__tests__/app.test.ts b/src/__tests__/app.test.ts new file mode 100644 index 0000000..c476557 --- /dev/null +++ b/src/__tests__/app.test.ts @@ -0,0 +1,75 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +jest.mock("@config/env", () => ({ + env: { + CORS_ORIGINS: ["http://localhost:5173", "http://example.com"], + CORS_CREDENTIALS: "true", + API_PREFIX: "/api/v1", + RESEND_API_KEY: "test-key" + } +})); + +jest.mock("bullmq", () => { + return { + Queue: jest.fn().mockImplementation(() => ({ + on: jest.fn(), + add: jest.fn(), + })), + Worker: jest.fn().mockImplementation(() => ({ + on: jest.fn(), + close: jest.fn(), + })), + }; +}); + +jest.mock("@lib/redis", () => require("./mocks/redis")); +jest.mock("@lib/prisma", () => require("./mocks/prisma")); + +jest.mock("@workers/email.worker", () => ({})); + +import request from "supertest"; +import app from "../app"; +import { env } from "@config/env"; + +describe("app.ts", () => { + test("GET /health should return status ok", async () => { + const res = await request(app).get("/health"); + expect(res.status).toBe(200); + expect(res.body.status).toBe("ok"); + expect(res.body.timestamp).toBeDefined(); + }); + + test("GET /docs.json should return swagger spec", async () => { + const res = await request(app).get("/docs.json"); + expect(res.status).toBe(200); + expect(res.body.openapi).toBeDefined(); + expect(res.body.info).toBeDefined(); + }); + + describe("CORS configuration", () => { + test("should allow requests from allowed origins", async () => { + // Assuming env.FRONTEND_URL or standard localhost is in CORS_ORIGINS + const validOrigin = env.CORS_ORIGINS[0] === "*" ? "http://example.com" : env.CORS_ORIGINS[0]; + const res = await request(app).options("/health").set("Origin", validOrigin); + expect(res.status).toBe(200); + expect(res.header["access-control-allow-origin"]).toBe(validOrigin); + }); + + test("should reject requests from disallowed origins", async () => { + if (env.CORS_ORIGINS.includes("*")) { + // Skip if everything is allowed + return; + } + const res = await request(app).options("/health").set("Origin", "http://malicious.com"); + // Cors middleware typically sends a 500 when the origin callback throws an error + expect(res.status).toBe(500); + }); + + test("should allow requests with no origin (e.g., server-to-server)", async () => { + const res = await request(app).get("/health"); // No Origin header set + expect(res.status).toBe(200); + }); + }); +}); diff --git a/src/__tests__/config/env.test.ts b/src/__tests__/config/env.test.ts new file mode 100644 index 0000000..752cc5d --- /dev/null +++ b/src/__tests__/config/env.test.ts @@ -0,0 +1,49 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +describe("config/env", () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.resetModules(); + process.env = {}; // Clear environment to force defaults + // suppress console.info + jest.spyOn(console, "info").mockImplementation(() => {}); + }); + + afterEach(() => { + process.env = originalEnv; + jest.restoreAllMocks(); + }); + + test("loads correct defaults when process.env variables are missing", async () => { + const { env } = await import("@config/env"); + + expect(env.NODE_ENV).toBe("development"); + expect(env.PORT).toBe(3000); + expect(env.DATABASE_URL).toBe(""); + expect(env.REDIS_URL).toBe("redis://localhost:6379"); + expect(env.CORS_ORIGINS).toEqual(["http://localhost:5173"]); + expect(env.CORS_CREDENTIALS).toBe("true"); + expect(env.JWT_SECRET).toBe(""); + expect(env.JWT_ACCESS_EXPIRY).toBe("15m"); + expect(env.JWT_REFRESH_EXPIRY_SECONDS).toBe(604800); + expect(env.RESET_TOKEN_TTL_SECONDS).toBe(900); + expect(env.API_PREFIX).toBe("/api/v1"); + + expect(env.FRONTEND_URL).toBe("http://localhost:5173"); + expect(env.GOOGLE_CLIENT_ID).toBe(""); + expect(env.GITHUB_CLIENT_ID).toBe(""); + expect(console.info).toHaveBeenCalled(); + }); + + test("loads provided values overriding defaults", async () => { + process.env.PORT = "4000"; + process.env.CORS_ORIGINS = "https://a.com,https://b.com"; + + const { env } = await import("@config/env"); + expect(env.PORT).toBe(4000); + expect(env.CORS_ORIGINS).toEqual(["https://a.com", "https://b.com"]); + }); +}); diff --git a/src/__tests__/controllers/auth.controller.test.ts b/src/__tests__/controllers/auth.controller.test.ts index e53b576..a180756 100644 --- a/src/__tests__/controllers/auth.controller.test.ts +++ b/src/__tests__/controllers/auth.controller.test.ts @@ -2,126 +2,197 @@ // Proprietary and confidential. Unauthorized use is strictly prohibited. // See LICENSE file in the project root for full license information. -import express from 'express'; -import request from 'supertest'; - -jest.mock('@services/auth.service'); -jest.mock('@services/oauth.service'); -jest.mock('@config/env', () => ({ env: { OAUTH_FRONTEND_URL: 'https://app.example.com' } })); - -import * as authService from '@services/auth.service'; -import * as oauthService from '@services/oauth.service'; -import { env } from '@config/env'; +import express from "express"; +import request from "supertest"; + +jest.mock("@services/auth.service"); +jest.mock("@services/oauth.service"); +jest.mock("@config/env", () => ({ env: { FRONTEND_URL: "https://app.example.com" } })); +jest.mock("@queues/email.queue", () => ({ + emailQueue: { + add: jest.fn(), + }, +})); + +import * as authService from "@services/auth.service"; +import * as oauthService from "@services/oauth.service"; +import { buildRedirectUrl } from "@utils/redirect"; +import { env } from "@config/env"; import { - signup, - login, - refresh, - logout, - forgotPassword, - resetPassword, - oauthRedirect, - oauthCallback, -} from '@api/controllers/auth.controller'; -import { AUTH_MESSAGES } from '@constants/messages'; + signup, + login, + refresh, + logout, + forgotPassword, + resetPassword, + oauthRedirect, + oauthCallback, +} from "@api/controllers/auth.controller"; +import { AUTH_MESSAGES } from "@constants/messages"; function makeApp() { - const a = express(); - a.use(express.json()); - a.post('/signup', signup); - a.post('/login', login); - a.post('/refresh', refresh); - a.post('/logout', logout); - a.post('/forgot', forgotPassword); - a.post('/reset', resetPassword); - a.get('/oauth/:provider/redirect', oauthRedirect); - a.get('/oauth/:provider/callback', oauthCallback); - return a; + const a = express(); + a.use(express.json()); + a.post("/signup", signup); + a.post("/login", login); + a.post("/refresh", refresh); + a.post("/logout", logout); + a.post("/forgot", forgotPassword); + a.post("/reset", resetPassword); + a.get("/oauth/:provider/redirect", oauthRedirect); + a.get("/oauth/:provider/callback", oauthCallback); + return a; } -describe('auth.controller', () => { - let app: express.Application; - - beforeEach(() => { - jest.resetAllMocks(); - app = makeApp(); - }); - - test('signup returns 201 on success', async () => { - (authService.signup as jest.Mock).mockResolvedValue({ message: 'ok' }); - const res = await request(app).post('/signup').send({ email: 'a@b.com', password: 'p', name: 'n' }); - expect(res.status).toBe(201); - expect(res.body).toEqual({ message: 'ok' }); - }); - - test('login returns 200 on success', async () => { - (authService.login as jest.Mock).mockResolvedValue({ tokens: {}, user: {} }); - const res = await request(app).post('/login').send({ email: 'a@b.com', password: 'p' }); - expect(res.status).toBe(200); - }); - - test('refresh returns 200 on success', async () => { - (authService.refresh as jest.Mock).mockResolvedValue({ tokens: {} }); - const res = await request(app).post('/refresh').send({ refreshToken: 'rt' }); - expect(res.status).toBe(200); - }); - - test('logout with refreshToken calls service', async () => { - (authService.logout as jest.Mock).mockResolvedValue({ message: 'logged out' }); - const res = await request(app).post('/logout').send({ refreshToken: 'x' }); - expect(res.status).toBe(200); - expect(authService.logout).toHaveBeenCalledWith('x'); - }); - - test('logout without refreshToken returns success message', async () => { - const res = await request(app).post('/logout').send({}); - expect(res.status).toBe(200); - expect(res.body.message).toBe(AUTH_MESSAGES.LOGOUT_SUCCESS); - }); - - test('forgotPassword triggers service and returns 200', async () => { - (authService.forgotPassword as jest.Mock).mockResolvedValue(undefined); - const res = await request(app).post('/forgot').send({ email: 'a@b.com' }); - expect(res.status).toBe(200); - expect(authService.forgotPassword).toHaveBeenCalledWith({ email: 'a@b.com' }); +describe("auth.controller", () => { + let app: express.Application; + + beforeEach(() => { + jest.resetAllMocks(); + app = makeApp(); + }); + + test("signup returns 201 on success", async () => { + (authService.signup as jest.Mock).mockResolvedValue({ message: "ok" }); + const res = await request(app).post("/signup").send({ email: "a@b.com", password: "p", name: "n" }); + expect(res.status).toBe(201); + expect(res.body).toEqual({ message: "ok" }); + }); + + test("login returns 200 on success", async () => { + (authService.login as jest.Mock).mockResolvedValue({ tokens: {}, user: {} }); + const res = await request(app).post("/login").send({ email: "a@b.com", password: "p" }); + expect(res.status).toBe(200); + }); + + test("refresh returns 200 on success", async () => { + (authService.refresh as jest.Mock).mockResolvedValue({ tokens: {} }); + const res = await request(app).post("/refresh").send({ refreshToken: "rt" }); + expect(res.status).toBe(200); + }); + + test("logout with refreshToken calls service", async () => { + (authService.logout as jest.Mock).mockResolvedValue({ message: "logged out" }); + const res = await request(app).post("/logout").send({ refreshToken: "x" }); + expect(res.status).toBe(200); + expect(authService.logout).toHaveBeenCalledWith("x"); + }); + + test("logout without refreshToken returns success message", async () => { + const res = await request(app).post("/logout").send({}); + expect(res.status).toBe(200); + expect(res.body.message).toBe(AUTH_MESSAGES.LOGOUT_SUCCESS); + }); + + test("forgotPassword triggers service and returns 200", async () => { + (authService.forgotPassword as jest.Mock).mockResolvedValue(undefined); + const res = await request(app).post("/forgot").send({ email: "a@b.com" }); + expect(res.status).toBe(200); + expect(authService.forgotPassword).toHaveBeenCalledWith({ email: "a@b.com" }); + }); + + test("resetPassword returns 200 on success", async () => { + (authService.resetPassword as jest.Mock).mockResolvedValue({ message: "ok" }); + const res = await request(app).post("/reset").send({ token: "t", newPassword: "np" }); + expect(res.status).toBe(200); + }); + + test("oauthRedirect returns 400 for unsupported provider", async () => { + const res = await request(app).get("/oauth/unknown/redirect"); + expect(res.status).toBe(400); + expect(res.body.message).toBe(AUTH_MESSAGES.UNSUPPORTED_OAUTH_PROVIDER); + }); + + test("oauthRedirect redirects to provider authorization url", async () => { + (oauthService.getAuthorizationUrl as jest.Mock).mockReturnValue("https://auth.example"); + const res = await request(app).get("/oauth/google/redirect"); + expect(res.status).toBe(302); + expect(res.header.location).toBe("https://auth.example"); + }); + + test("oauthCallback redirects to login when code missing", async () => { + const res = await request(app).get("/oauth/google/callback"); + expect(res.status).toBe(302); + expect(res.header.location).toBe( + buildRedirectUrl(env.FRONTEND_URL, "/login", { error: AUTH_MESSAGES.OAUTH_CODE_MISSING }), + ); + }); + + test("oauthCallback redirects back to frontend on success", async () => { + (oauthService.exchangeCodeForProfile as jest.Mock).mockResolvedValue({ email: "a@b.com", name: "A" }); + (authService.oauthLogin as jest.Mock).mockResolvedValue({ + tokens: { accessToken: "a", refreshToken: "r" }, + user: { id: "u", email: "a@b.com", name: "A" }, }); - - test('resetPassword returns 200 on success', async () => { - (authService.resetPassword as jest.Mock).mockResolvedValue({ message: 'ok' }); - const res = await request(app).post('/reset').send({ token: 't', newPassword: 'np' }); - expect(res.status).toBe(200); + const res = await request(app).get("/oauth/google/callback").query({ code: "c" }); + expect(res.status).toBe(302); + expect(res.header.location).toContain(`${env.FRONTEND_URL}/oauth/callback?`); + }); + + test("oauthCallback redirects to login with error when exchange fails", async () => { + (oauthService.exchangeCodeForProfile as jest.Mock).mockRejectedValue(new Error("fail")); + const res = await request(app).get("/oauth/google/callback").query({ code: "c" }); + expect(res.status).toBe(302); + expect(res.header.location).toBe(buildRedirectUrl(env.FRONTEND_URL, "/login", { error: "fail" })); + }); + + describe("error handling catch blocks", () => { + test("signup next(err) on service failure", async () => { + (authService.signup as jest.Mock).mockRejectedValue(new Error("signup fail")); + const res = await request(app).post("/signup").send({}); + expect(res.status).toBe(500); }); - test('oauthRedirect returns 400 for unsupported provider', async () => { - const res = await request(app).get('/oauth/unknown/redirect'); - expect(res.status).toBe(400); - expect(res.body.message).toBe(AUTH_MESSAGES.UNSUPPORTED_OAUTH_PROVIDER); + test("login next(err)", async () => { + (authService.login as jest.Mock).mockRejectedValue(new Error("login fail")); + const res = await request(app).post("/login").send({}); + expect(res.status).toBe(500); }); - test('oauthRedirect redirects to provider authorization url', async () => { - (oauthService.getAuthorizationUrl as jest.Mock).mockReturnValue('https://auth.example'); - const res = await request(app).get('/oauth/google/redirect'); - expect(res.status).toBe(302); - expect(res.header.location).toBe('https://auth.example'); + test("refresh next(err)", async () => { + (authService.refresh as jest.Mock).mockRejectedValue(new Error("refresh fail")); + const res = await request(app).post("/refresh").send({}); + expect(res.status).toBe(500); }); - test('oauthCallback redirects to login when code missing', async () => { - const res = await request(app).get('/oauth/google/callback'); - expect(res.status).toBe(302); - expect(res.header.location).toBe(`${env.OAUTH_FRONTEND_URL}/login?error=${encodeURIComponent(AUTH_MESSAGES.OAUTH_CODE_MISSING)}`); + test("logout next(err)", async () => { + (authService.logout as jest.Mock).mockRejectedValue(new Error("logout fail")); + const res = await request(app).post("/logout").send({ refreshToken: "x" }); + expect(res.status).toBe(500); }); - test('oauthCallback redirects back to frontend on success', async () => { - (oauthService.exchangeCodeForProfile as jest.Mock).mockResolvedValue({ email: 'a@b.com', name: 'A' }); - (authService.oauthLogin as jest.Mock).mockResolvedValue({ tokens: { accessToken: 'a', refreshToken: 'r' }, user: { id: 'u', email: 'a@b.com', name: 'A' } }); - const res = await request(app).get('/oauth/google/callback').query({ code: 'c' }); - expect(res.status).toBe(302); - expect(res.header.location).toContain(`${env.OAUTH_FRONTEND_URL}/oauth/callback?`); + test("forgotPassword next(err)", async () => { + const mockReq = { body: {} } as any; + const mockRes = {} as any; + const nextFn = jest.fn(); + + const originalForgot = authService.forgotPassword; + Object.defineProperty(authService, "forgotPassword", { value: () => { throw new Error("sync throw"); } }); + + await forgotPassword(mockReq, mockRes, nextFn); + expect(nextFn).toHaveBeenCalledWith(new Error("sync throw")); + + Object.defineProperty(authService, "forgotPassword", { value: originalForgot }); }); - test('oauthCallback redirects to login with error when exchange fails', async () => { - (oauthService.exchangeCodeForProfile as jest.Mock).mockRejectedValue(new Error('fail')); - const res = await request(app).get('/oauth/google/callback').query({ code: 'c' }); - expect(res.status).toBe(302); - expect(res.header.location).toContain('/login?error='); + test("resetPassword next(err)", async () => { + (authService.resetPassword as jest.Mock).mockRejectedValue(new Error("reset fail")); + const res = await request(app).post("/reset").send({}); + expect(res.status).toBe(500); }); + + test("oauthRedirect next(err)", async () => { + const mockReq = { params: { provider: "google" } } as any; + const mockRes = {} as any; + const nextFn = jest.fn(); + + const originalRedirect = oauthService.getAuthorizationUrl; + Object.defineProperty(oauthService, "getAuthorizationUrl", { value: () => { throw new Error("sync throw auth"); } }); + + await oauthRedirect(mockReq, mockRes, nextFn); + expect(nextFn).toHaveBeenCalledWith(new Error("sync throw auth")); + + Object.defineProperty(oauthService, "getAuthorizationUrl", { value: originalRedirect }); + }); + }); }); diff --git a/src/__tests__/integration/auth.routes.test.ts b/src/__tests__/integration/auth.routes.test.ts index a43daa4..16af18e 100644 --- a/src/__tests__/integration/auth.routes.test.ts +++ b/src/__tests__/integration/auth.routes.test.ts @@ -6,34 +6,47 @@ * @file Integration tests for all POST /auth/* routes via supertest. */ -import request from 'supertest'; -import jwt from 'jsonwebtoken'; -import { AUTH_MESSAGES, AUTH_VALIDATION } from '@constants/messages'; - -jest.mock('@lib/prisma', () => require('../mocks/prisma')); -jest.mock('@lib/redis', () => require('../mocks/redis')); -jest.mock('@config/env', () => ({ - env: { - NODE_ENV: 'test', - PORT: 3000, - JWT_SECRET: 'test-secret', - JWT_ACCESS_EXPIRY: '15m', - JWT_REFRESH_EXPIRY_SECONDS: 604800, - RESET_TOKEN_TTL_SECONDS: 900, - API_PREFIX: '/api/v1', - }, +import { AUTH_MESSAGES, AUTH_VALIDATION } from "@constants/messages"; +import jwt from "jsonwebtoken"; +import request from "supertest"; + +jest.mock("@workers/email.worker", () => ({})); +jest.mock("@lib/prisma", () => require("../mocks/prisma")); +jest.mock("@lib/redis", () => require("../mocks/redis")); +jest.mock("@services/email.service", () => ({ + sendResetEmail: jest.fn().mockResolvedValue(undefined), +})); +jest.mock("@queues/email.queue", () => ({ + emailQueue: { + add: jest.fn(), + }, +})); + +jest.mock("@config/env", () => ({ + env: { + NODE_ENV: "test", + PORT: 3000, + JWT_SECRET: "test-secret", + JWT_ACCESS_EXPIRY: "15m", + JWT_REFRESH_EXPIRY_SECONDS: 604800, + RESET_TOKEN_TTL_SECONDS: 3600, + API_PREFIX: "/api/v1", + RESEND_API_KEY: "test-key", + RESET_PASSWORD_EMAIL: "test@test.com", + FRONTEND_URL: "https://app.example.com", + }, })); -jest.mock('argon2', () => ({ - hash: jest.fn().mockResolvedValue('$argon2-hashed'), - verify: jest.fn(), +jest.mock("argon2", () => ({ + hash: jest.fn().mockResolvedValue("$argon2-hashed"), + verify: jest.fn(), })); -import argon2 from 'argon2'; -import prisma from '@lib/prisma'; -import redis from '@lib/redis'; -import app from '../../app'; -import { env } from '@config/env'; +import { env } from "@config/env"; +import prisma from "@lib/prisma"; +import redis from "@lib/redis"; +import argon2 from "argon2"; +import app from "../../app"; const BASE = `${env.API_PREFIX}/auth`; @@ -41,306 +54,282 @@ const mockPrisma = prisma as any; // eslint-disable-line @typescript-eslint/no-e const mockRedis = redis as any; // eslint-disable-line @typescript-eslint/no-explicit-any const mockArgon2 = argon2 as any; // eslint-disable-line @typescript-eslint/no-explicit-any -describe('POST /auth/signup', () => { - it('should return 201 on successful signup', async () => { - mockPrisma.user.findUnique.mockResolvedValue(null); - mockPrisma.user.create.mockResolvedValue({ - id: 'uuid-1', - email: 'new@user.com', - name: 'New User', - password: '$argon2-hashed', - createdAt: new Date(), - updatedAt: new Date(), - }); - - const res = await request(app) - .post(`${BASE}/signup`) - .send({ email: 'new@user.com', password: 'P@ssword1', name: 'New User' }); - - expect(res.status).toBe(201); - expect(res.body.message).toBe(AUTH_MESSAGES.SIGNUP_SUCCESS); +describe("POST /auth/signup", () => { + it("should return 201 on successful signup", async () => { + mockPrisma.user.findUnique.mockResolvedValue(null); + mockPrisma.user.create.mockResolvedValue({ + id: "uuid-1", + email: "new@user.com", + name: "New User", + password: "$argon2-hashed", + createdAt: new Date(), + updatedAt: new Date(), }); - it('should return 409 if email already exists', async () => { - mockPrisma.user.findUnique.mockResolvedValue({ - id: 'uuid-1', - email: 'dup@user.com', - name: 'Dup', - password: 'hashed', - createdAt: new Date(), - updatedAt: new Date(), - }); - - const res = await request(app) - .post(`${BASE}/signup`) - .send({ email: 'dup@user.com', password: 'P@ssword1', name: 'Dup' }); - - expect(res.status).toBe(409); - expect(res.body.message).toBe(AUTH_MESSAGES.EMAIL_TAKEN); + const res = await request(app) + .post(`${BASE}/signup`) + .send({ email: "new@user.com", password: "P@ssword1", name: "New User" }); + + expect(res.status).toBe(201); + expect(res.body.message).toBe(AUTH_MESSAGES.SIGNUP_SUCCESS); + }); + + it("should return 409 if email already exists", async () => { + mockPrisma.user.findUnique.mockResolvedValue({ + id: "uuid-1", + email: "dup@user.com", + name: "Dup", + password: "hashed", + createdAt: new Date(), + updatedAt: new Date(), }); - it('should not return any tokens or set cookies', async () => { - mockPrisma.user.findUnique.mockResolvedValue(null); - mockPrisma.user.create.mockResolvedValue({ - id: 'uuid-1', - email: 'a@b.com', - name: 'User', - password: '$argon2-hashed', - createdAt: new Date(), - updatedAt: new Date(), - }); - - const res = await request(app) - .post(`${BASE}/signup`) - .send({ email: 'a@b.com', password: 'P@ssword1', name: 'User' }); - - expect(res.status).toBe(201); - expect(res.body.tokens).toBeUndefined(); + const res = await request(app) + .post(`${BASE}/signup`) + .send({ email: "dup@user.com", password: "P@ssword1", name: "Dup" }); + + expect(res.status).toBe(409); + expect(res.body.message).toBe(AUTH_MESSAGES.EMAIL_TAKEN); + }); + + it("should not return any tokens or set cookies", async () => { + mockPrisma.user.findUnique.mockResolvedValue(null); + mockPrisma.user.create.mockResolvedValue({ + id: "uuid-1", + email: "a@b.com", + name: "User", + password: "$argon2-hashed", + createdAt: new Date(), + updatedAt: new Date(), }); + + const res = await request(app) + .post(`${BASE}/signup`) + .send({ email: "a@b.com", password: "P@ssword1", name: "User" }); + + expect(res.status).toBe(201); + expect(res.body.tokens).toBeUndefined(); + }); }); -describe('POST /auth/login', () => { - it('should return 200 with user info and tokens in response body', async () => { - mockPrisma.user.findUnique.mockResolvedValue({ - id: 'uuid-1', - email: 'user@test.com', - name: 'Test', - password: '$argon2-hashed', - createdAt: new Date(), - updatedAt: new Date(), - }); - mockArgon2.verify.mockResolvedValue(true); - mockRedis.set.mockResolvedValue('OK'); - - const res = await request(app) - .post(`${BASE}/login`) - .send({ email: 'user@test.com', password: 'P@ssword1' }); - - expect(res.status).toBe(200); - expect(res.body.user).toEqual({ id: 'uuid-1', email: 'user@test.com', name: 'Test' }); - expect(res.body.tokens).toBeDefined(); - expect(res.body.tokens.accessToken).toBeDefined(); - expect(res.body.tokens.refreshToken).toBeDefined(); +describe("POST /auth/login", () => { + it("should return 200 with user info and tokens in response body", async () => { + mockPrisma.user.findUnique.mockResolvedValue({ + id: "uuid-1", + email: "user@test.com", + name: "Test", + password: "$argon2-hashed", + createdAt: new Date(), + updatedAt: new Date(), + }); + mockArgon2.verify.mockResolvedValue(true); + mockRedis.set.mockResolvedValue("OK"); + + const res = await request(app).post(`${BASE}/login`).send({ email: "user@test.com", password: "P@ssword1" }); + + expect(res.status).toBe(200); + expect(res.body.user).toEqual({ id: "uuid-1", email: "user@test.com", name: "Test" }); + expect(res.body.tokens).toBeDefined(); + expect(res.body.tokens.accessToken).toBeDefined(); + expect(res.body.tokens.refreshToken).toBeDefined(); + }); + + it("should return 401 for wrong email", async () => { + mockPrisma.user.findUnique.mockResolvedValue(null); + + const res = await request(app).post(`${BASE}/login`).send({ email: "wrong@test.com", password: "P@ssword1" }); + + expect(res.status).toBe(401); + expect(res.body.message).toBe(AUTH_MESSAGES.INVALID_CREDENTIALS); + }); + + it("should return 401 for wrong password", async () => { + mockPrisma.user.findUnique.mockResolvedValue({ + id: "uuid-1", + email: "user@test.com", + name: "Test", + password: "$argon2-hashed", + createdAt: new Date(), + updatedAt: new Date(), }); + mockArgon2.verify.mockResolvedValue(false); - it('should return 401 for wrong email', async () => { - mockPrisma.user.findUnique.mockResolvedValue(null); + const res = await request(app).post(`${BASE}/login`).send({ email: "user@test.com", password: "WrongPass" }); - const res = await request(app) - .post(`${BASE}/login`) - .send({ email: 'wrong@test.com', password: 'P@ssword1' }); + expect(res.status).toBe(401); + expect(res.body.message).toBe(AUTH_MESSAGES.INVALID_CREDENTIALS); + }); +}); - expect(res.status).toBe(401); - expect(res.body.message).toBe(AUTH_MESSAGES.INVALID_CREDENTIALS); - }); +describe("POST /auth/refresh", () => { + it("should return 200 and rotate tokens on valid refresh token", async () => { + const oldToken = "valid-refresh-token"; + const crypto = require("crypto"); + const hashed = crypto.createHash("sha256").update(oldToken).digest("hex"); + const key = `refresh:uuid-1:${hashed}`; - it('should return 401 for wrong password', async () => { - mockPrisma.user.findUnique.mockResolvedValue({ - id: 'uuid-1', - email: 'user@test.com', - name: 'Test', - password: '$argon2-hashed', - createdAt: new Date(), - updatedAt: new Date(), - }); - mockArgon2.verify.mockResolvedValue(false); - - const res = await request(app) - .post(`${BASE}/login`) - .send({ email: 'user@test.com', password: 'WrongPass' }); - - expect(res.status).toBe(401); - expect(res.body.message).toBe(AUTH_MESSAGES.INVALID_CREDENTIALS); - }); -}); + mockRedis.scan.mockResolvedValueOnce(["0", [key]]); + mockRedis.del.mockResolvedValue(1); + mockRedis.set.mockResolvedValue("OK"); -describe('POST /auth/refresh', () => { - it('should return 200 and rotate tokens on valid refresh token', async () => { - const oldToken = 'valid-refresh-token'; - const crypto = require('crypto'); - const hashed = crypto.createHash('sha256').update(oldToken).digest('hex'); - const key = `refresh:uuid-1:${hashed}`; - - mockRedis.scan.mockResolvedValueOnce(['0', [key]]); - mockRedis.del.mockResolvedValue(1); - mockRedis.set.mockResolvedValue('OK'); - - const res = await request(app) - .post(`${BASE}/refresh`) - .send({ refreshToken: oldToken }); - - expect(res.status).toBe(200); - expect(res.body.message).toBe(AUTH_MESSAGES.REFRESH_SUCCESS); - expect(res.body.tokens).toBeDefined(); - expect(res.body.tokens.accessToken).toBeDefined(); - expect(res.body.tokens.refreshToken).toBeDefined(); - }); + const res = await request(app).post(`${BASE}/refresh`).send({ refreshToken: oldToken }); - it('should return 401 if no refresh token in body', async () => { - const res = await request(app) - .post(`${BASE}/refresh`) - .send({}); + expect(res.status).toBe(200); + expect(res.body.message).toBe(AUTH_MESSAGES.REFRESH_SUCCESS); + expect(res.body.tokens).toBeDefined(); + expect(res.body.tokens.accessToken).toBeDefined(); + expect(res.body.tokens.refreshToken).toBeDefined(); + }); - expect(res.status).toBe(400); - expect(res.body.message).toBe(AUTH_VALIDATION.REFRESH_TOKEN_REQUIRED); - }); + it("should return 401 if no refresh token in body", async () => { + const res = await request(app).post(`${BASE}/refresh`).send({}); - it('should return 401 if refresh token not in Redis', async () => { - mockRedis.scan.mockResolvedValue(['0', []]); + expect(res.status).toBe(400); + expect(res.body.message).toBe(AUTH_VALIDATION.REFRESH_TOKEN_REQUIRED); + }); - const res = await request(app) - .post(`${BASE}/refresh`) - .send({ refreshToken: 'bad-token' }); + it("should return 401 if refresh token not in Redis", async () => { + mockRedis.scan.mockResolvedValue(["0", []]); - expect(res.status).toBe(401); - }); + const res = await request(app).post(`${BASE}/refresh`).send({ refreshToken: "bad-token" }); + + expect(res.status).toBe(401); + }); }); -describe('POST /auth/logout', () => { - it('should return 200 and invalidate refresh token', async () => { - const token = 'some-refresh-token'; - const crypto = require('crypto'); - const hashed = crypto.createHash('sha256').update(token).digest('hex'); - const key = `refresh:uuid-1:${hashed}`; +describe("POST /auth/logout", () => { + it("should return 200 and invalidate refresh token", async () => { + const token = "some-refresh-token"; + const crypto = require("crypto"); + const hashed = crypto.createHash("sha256").update(token).digest("hex"); + const key = `refresh:uuid-1:${hashed}`; - mockRedis.scan.mockResolvedValueOnce(['0', [key]]); - mockRedis.del.mockResolvedValue(1); + mockRedis.scan.mockResolvedValueOnce(["0", [key]]); + mockRedis.del.mockResolvedValue(1); - const res = await request(app) - .post(`${BASE}/logout`) - .send({ refreshToken: token }); + const res = await request(app).post(`${BASE}/logout`).send({ refreshToken: token }); - expect(res.status).toBe(200); - expect(res.body.message).toBe(AUTH_MESSAGES.LOGOUT_SUCCESS); - }); + expect(res.status).toBe(200); + expect(res.body.message).toBe(AUTH_MESSAGES.LOGOUT_SUCCESS); + }); - it('should return 200 even without a refresh token (graceful)', async () => { - const res = await request(app) - .post(`${BASE}/logout`) - .send({}); + it("should return 200 even without a refresh token (graceful)", async () => { + const res = await request(app).post(`${BASE}/logout`).send({}); - expect(res.status).toBe(200); - expect(res.body.message).toBe(AUTH_MESSAGES.LOGOUT_SUCCESS); - }); + expect(res.status).toBe(200); + expect(res.body.message).toBe(AUTH_MESSAGES.LOGOUT_SUCCESS); + }); }); -describe('POST /auth/forgot-password', () => { - it('should always return 200 regardless of email existence', async () => { - mockPrisma.user.findUnique.mockResolvedValue(null); +describe("POST /auth/forgot-password", () => { + it("should always return 200 regardless of email existence", async () => { + mockPrisma.user.findUnique.mockResolvedValue(null); - const res = await request(app) - .post(`${BASE}/forgot-password`) - .send({ email: 'nonexistent@test.com' }); + const res = await request(app).post(`${BASE}/forgot-password`).send({ email: "nonexistent@test.com" }); - expect(res.status).toBe(200); - expect(res.body.message).toContain('If an account'); - }); + expect(res.status).toBe(200); + expect(res.body.message).toContain("If an account"); + }); - it('should return 200 for existing email (same response)', async () => { - mockPrisma.user.findUnique.mockResolvedValue({ - id: 'uuid-1', - email: 'exists@test.com', - name: 'Test', - password: 'hashed', - createdAt: new Date(), - updatedAt: new Date(), - }); - mockRedis.set.mockResolvedValue('OK'); - - const res = await request(app) - .post(`${BASE}/forgot-password`) - .send({ email: 'exists@test.com' }); - - expect(res.status).toBe(200); - expect(res.body.message).toContain('If an account'); + it("should return 200 for existing email (same response)", async () => { + mockPrisma.user.findUnique.mockResolvedValue({ + id: "uuid-1", + email: "exists@test.com", + name: "Test", + password: "hashed", + createdAt: new Date(), + updatedAt: new Date(), }); + mockRedis.set.mockResolvedValue("OK"); - it('should not leak whether the email exists via timing or response', async () => { - mockPrisma.user.findUnique.mockResolvedValue(null); - const res1 = await request(app) - .post(`${BASE}/forgot-password`) - .send({ email: 'a@b.com' }); - - mockPrisma.user.findUnique.mockResolvedValue({ - id: 'uuid-1', - email: 'a@b.com', - name: 'T', - password: 'h', - createdAt: new Date(), - updatedAt: new Date(), - }); - mockRedis.set.mockResolvedValue('OK'); - const res2 = await request(app) - .post(`${BASE}/forgot-password`) - .send({ email: 'a@b.com' }); - - expect(res1.body).toEqual(res2.body); - expect(res1.status).toBe(res2.status); - }); -}); + const res = await request(app).post(`${BASE}/forgot-password`).send({ email: "exists@test.com" }); -describe('POST /auth/reset-password', () => { - it('should return 200 on successful password reset', async () => { - const crypto = require('crypto'); - const rawToken = 'valid-reset-token'; - const hashed = crypto.createHash('sha256').update(rawToken).digest('hex'); - - mockRedis.get.mockResolvedValue('uuid-1'); - mockArgon2.hash.mockResolvedValue('$argon2-new-hashed'); - mockPrisma.user.update.mockResolvedValue({ - id: 'uuid-1', - email: 'a@b.com', - name: 'Test', - password: '$argon2-new-hashed', - createdAt: new Date(), - updatedAt: new Date(), - }); - mockRedis.del.mockResolvedValue(1); - mockRedis.scan.mockResolvedValueOnce(['0', []]); - - const res = await request(app) - .post(`${BASE}/reset-password`) - .send({ token: rawToken, newPassword: 'NewP@ssword1' }); - - expect(res.status).toBe(200); - expect(res.body.message).toBe(AUTH_MESSAGES.RESET_PASSWORD_SUCCESS); - expect(mockRedis.get).toHaveBeenCalledWith(`reset:${hashed}`); - }); + expect(res.status).toBe(200); + expect(res.body.message).toContain("If an account"); + }); - it('should return 400 for expired/invalid reset token', async () => { - mockRedis.get.mockResolvedValue(null); + it("should not leak whether the email exists via timing or response", async () => { + mockPrisma.user.findUnique.mockResolvedValue(null); + const res1 = await request(app).post(`${BASE}/forgot-password`).send({ email: "a@b.com" }); - const res = await request(app) - .post(`${BASE}/reset-password`) - .send({ token: 'bad-token', newPassword: 'NewP@ssword1' }); - - expect(res.status).toBe(400); - expect(res.body.message).toBe(AUTH_MESSAGES.RESET_TOKEN_INVALID); + mockPrisma.user.findUnique.mockResolvedValue({ + id: "uuid-1", + email: "a@b.com", + name: "T", + password: "h", + createdAt: new Date(), + updatedAt: new Date(), }); + mockRedis.set.mockResolvedValue("OK"); + const res2 = await request(app).post(`${BASE}/forgot-password`).send({ email: "a@b.com" }); + + expect(res1.body).toEqual(res2.body); + expect(res1.status).toBe(res2.status); + }); +}); - it('should purge all refresh tokens (global logout) after reset', async () => { - const crypto = require('crypto'); - const rawToken = 'reset-tok'; - const hashed = crypto.createHash('sha256').update(rawToken).digest('hex'); - - mockRedis.get.mockResolvedValue('uuid-1'); - mockArgon2.hash.mockResolvedValue('$argon2-new'); - mockPrisma.user.update.mockResolvedValue({ - id: 'uuid-1', - email: 'a@b.com', - name: 'T', - password: '$argon2-new', - createdAt: new Date(), - updatedAt: new Date(), - }); - mockRedis.del.mockResolvedValue(1); - mockRedis.scan.mockResolvedValueOnce(['0', ['refresh:uuid-1:abc']]); - - await request(app) - .post(`${BASE}/reset-password`) - .send({ token: rawToken, newPassword: 'NewP@ss1' }); - - expect(mockRedis.del).toHaveBeenCalledWith(`reset:${hashed}`); - expect(mockRedis.del).toHaveBeenCalledWith('refresh:uuid-1:abc'); +describe("POST /auth/reset-password", () => { + it("should return 200 on successful password reset", async () => { + const crypto = require("crypto"); + const rawToken = "secure-reset-token"; + const hashed = crypto.createHash("sha256").update(rawToken).digest("hex"); + + mockRedis.get.mockResolvedValue("uuid-1"); + mockArgon2.hash.mockResolvedValue("$argon2-new-hashed"); + mockPrisma.user.update.mockResolvedValue({ + id: "uuid-1", + email: "a@b.com", + name: "Test", + password: "$argon2-new-hashed", + createdAt: new Date(), + updatedAt: new Date(), }); + mockRedis.del.mockResolvedValue(1); + mockRedis.scan.mockResolvedValueOnce(["0", []]); + + const res = await request(app) + .post(`${BASE}/reset-password`) + .send({ token: rawToken, newPassword: "NewP@ssword1" }); + + expect(res.status).toBe(200); + expect(res.body.message).toBe(AUTH_MESSAGES.RESET_PASSWORD_SUCCESS); + expect(mockRedis.get).toHaveBeenCalledWith(`reset:${hashed}`); + }); + + it("should return 400 for invalid reset token", async () => { + mockRedis.get.mockResolvedValue(null); + + const res = await request(app) + .post(`${BASE}/reset-password`) + .send({ token: "000000", newPassword: "NewP@ssword1" }); + + expect(res.status).toBe(400); + expect(res.body.message).toBe(AUTH_MESSAGES.RESET_TOKEN_INVALID); + }); + + it("should purge all refresh tokens (global logout) after reset", async () => { + const crypto = require("crypto"); + const rawToken = "another-reset-token"; + const hashed = crypto.createHash("sha256").update(rawToken).digest("hex"); + + mockRedis.get.mockResolvedValue("uuid-1"); + mockArgon2.hash.mockResolvedValue("$argon2-new"); + mockPrisma.user.update.mockResolvedValue({ + id: "uuid-1", + email: "a@b.com", + name: "T", + password: "$argon2-new", + createdAt: new Date(), + updatedAt: new Date(), + }); + mockRedis.del.mockResolvedValue(1); + mockRedis.scan.mockResolvedValueOnce(["0", ["refresh:uuid-1:abc"]]); + + await request(app) + .post(`${BASE}/reset-password`) + .send({ token: rawToken, newPassword: "NewP@ss1" }); + + expect(mockRedis.del).toHaveBeenCalledWith(`reset:${hashed}`); + expect(mockRedis.del).toHaveBeenCalledWith("refresh:uuid-1:abc"); + }); }); diff --git a/src/__tests__/lib/redis.test.ts b/src/__tests__/lib/redis.test.ts new file mode 100644 index 0000000..c59681f --- /dev/null +++ b/src/__tests__/lib/redis.test.ts @@ -0,0 +1,50 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +import redis, { scanKeys } from "@lib/redis"; + +describe("lib/redis", () => { + beforeEach(() => { + jest.resetModules(); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + test("scanKeys iterables properly", async () => { + jest.unmock("@lib/redis"); + const actualRedis = jest.requireActual("@lib/redis"); + + jest.spyOn(actualRedis.default, "scan") + .mockResolvedValueOnce(["1", ["key1"]]) + .mockResolvedValueOnce(["0", ["key2"]]); + + const result = await actualRedis.scanKeys("pattern:*"); + expect(result).toEqual(["key1", "key2"]); + }); + + test("retryStrategy limits and scales", () => { + jest.unmock("ioredis"); + const { default: Redis } = jest.requireActual("ioredis"); + jest.unmock("@lib/redis"); + const actualRedisLib = jest.requireActual("@lib/redis"); + }); + + test("redis error handler gets coverage", () => { + jest.spyOn(console, "error").mockImplementation(); + + const redis = require("@lib/redis").default; + + const onMock = redis.on as jest.Mock; + const errorCall = onMock.mock.calls.find(call => call[0] === 'error'); + + if (errorCall && errorCall[1]) { + const cb = errorCall[1]; + cb(new Error("Simulated Redis Error")); + } + + expect(console.error).toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/middlewares/logger.test.ts b/src/__tests__/middlewares/logger.test.ts new file mode 100644 index 0000000..c69d97b --- /dev/null +++ b/src/__tests__/middlewares/logger.test.ts @@ -0,0 +1,25 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +import { httpLogger } from "@api/middlewares/logger"; +import { Request, Response } from "express"; + +describe("api/middlewares/logger", () => { + it("determines log level based on response status code", () => { + const customLogLevel = (httpLogger as any).customLogLevel; + + if (customLogLevel) { + expect(customLogLevel({} as Request, { statusCode: 500 } as Response, undefined)).toBe("error"); + expect(customLogLevel({} as Request, { statusCode: 503 } as Response, undefined)).toBe("error"); + + expect(customLogLevel({} as Request, { statusCode: 200 } as Response, new Error("Oops"))).toBe("error"); + + expect(customLogLevel({} as Request, { statusCode: 400 } as Response, undefined)).toBe("warn"); + expect(customLogLevel({} as Request, { statusCode: 404 } as Response, undefined)).toBe("warn"); + + expect(customLogLevel({} as Request, { statusCode: 200 } as Response, undefined)).toBe("info"); + expect(customLogLevel({} as Request, { statusCode: 302 } as Response, undefined)).toBe("info"); + } + }); +}); diff --git a/src/__tests__/middlewares/requireAuth.test.ts b/src/__tests__/middlewares/requireAuth.test.ts index d94cd84..35b67a1 100644 --- a/src/__tests__/middlewares/requireAuth.test.ts +++ b/src/__tests__/middlewares/requireAuth.test.ts @@ -6,125 +6,120 @@ * @file Unit tests for requireAuth and errorHandler middlewares. */ -import request from 'supertest'; -import jwt from 'jsonwebtoken'; - -jest.mock('@lib/prisma', () => require('../mocks/prisma')); -jest.mock('@lib/redis', () => require('../mocks/redis')); -jest.mock('@config/env', () => ({ - env: { - NODE_ENV: 'test', - PORT: 3000, - JWT_SECRET: 'test-secret', - JWT_ACCESS_EXPIRY: '15m', - JWT_REFRESH_EXPIRY_SECONDS: 604800, - RESET_TOKEN_TTL_SECONDS: 900, - }, +import jwt from "jsonwebtoken"; +import request from "supertest"; + +jest.mock("@lib/prisma", () => require("../mocks/prisma")); +jest.mock("@lib/redis", () => require("../mocks/redis")); +jest.mock("@queues/email.queue", () => ({ + emailQueue: { + add: jest.fn(), + }, +})); +jest.mock("@config/env", () => ({ + env: { + NODE_ENV: "test", + PORT: 3000, + JWT_SECRET: "test-secret", + JWT_ACCESS_EXPIRY: "15m", + JWT_REFRESH_EXPIRY_SECONDS: 604800, + RESET_TOKEN_TTL_SECONDS: 3600, + }, })); -import app from '../../app'; +import app from "../../app"; -import express from 'express'; -import { logger } from '@services/logger.service'; -import { requireAuth } from '@api/middlewares/requireAuth'; -import { errorHandler } from '@api/middlewares/errorHandler'; +import { errorHandler } from "@api/middlewares/errorHandler"; +import { requireAuth } from "@api/middlewares/requireAuth"; +import { logger } from "@services/logger.service"; +import express from "express"; /** Creates a minimal Express app with a protected route for testing middleware. */ function createTestApp(): express.Application { - const testApp = express(); - testApp.use(express.json()); + const testApp = express(); + testApp.use(express.json()); - testApp.get('/protected', requireAuth, (req, res) => { - res.json({ userId: req.userId }); - }); + testApp.get("/protected", requireAuth, (req, res) => { + res.json({ userId: req.userId }); + }); - testApp.use(errorHandler); - return testApp; + testApp.use(errorHandler); + return testApp; } const testApp = createTestApp(); -describe('requireAuth middleware', () => { - it('should attach userId and allow access with valid token', async () => { - const token = jwt.sign({ sub: 'uuid-1' }, 'test-secret', { expiresIn: '15m' }); +describe("requireAuth middleware", () => { + it("should attach userId and allow access with valid token", async () => { + const token = jwt.sign({ sub: "uuid-1" }, "test-secret", { expiresIn: "15m" }); - const res = await request(testApp) - .get('/protected') - .set('Authorization', `Bearer ${token}`); + const res = await request(testApp).get("/protected").set("Authorization", `Bearer ${token}`); - expect(res.status).toBe(200); - expect(res.body.userId).toBe('uuid-1'); - }); + expect(res.status).toBe(200); + expect(res.body.userId).toBe("uuid-1"); + }); - it('should return 401 when no Authorization header is present', async () => { - const res = await request(testApp).get('/protected'); + it("should return 401 when no Authorization header is present", async () => { + const res = await request(testApp).get("/protected"); - expect(res.status).toBe(401); - expect(res.body.message).toBe('Invalid or expired access token'); - }); + expect(res.status).toBe(401); + expect(res.body.message).toBe("Invalid or expired access token"); + }); - it('should return 401 when token has invalid signature', async () => { - const token = jwt.sign({ sub: 'uuid-1' }, 'wrong-secret'); + it("should return 401 when token has invalid signature", async () => { + const token = jwt.sign({ sub: "uuid-1" }, "wrong-secret"); - const res = await request(testApp) - .get('/protected') - .set('Authorization', `Bearer ${token}`); + const res = await request(testApp).get("/protected").set("Authorization", `Bearer ${token}`); - expect(res.status).toBe(401); - }); + expect(res.status).toBe(401); + }); - it('should return 401 when token is expired', async () => { - const token = jwt.sign({ sub: 'uuid-1' }, 'test-secret', { expiresIn: '-1s' }); + it("should return 401 when token is expired", async () => { + const token = jwt.sign({ sub: "uuid-1" }, "test-secret", { expiresIn: "-1s" }); - const res = await request(testApp) - .get('/protected') - .set('Authorization', `Bearer ${token}`); + const res = await request(testApp).get("/protected").set("Authorization", `Bearer ${token}`); - expect(res.status).toBe(401); - }); + expect(res.status).toBe(401); + }); - it('should return 401 when token has no sub claim', async () => { - const token = jwt.sign({ foo: 'bar' }, 'test-secret'); + it("should return 401 when token has no sub claim", async () => { + const token = jwt.sign({ foo: "bar" }, "test-secret"); - const res = await request(testApp) - .get('/protected') - .set('Authorization', `Bearer ${token}`); + const res = await request(testApp).get("/protected").set("Authorization", `Bearer ${token}`); - expect(res.status).toBe(401); - }); + expect(res.status).toBe(401); + }); - it('should return 401 for garbage token value', async () => { - const res = await request(testApp) - .get('/protected') - .set('Authorization', 'Bearer not.a.jwt'); + it("should return 401 for garbage token value", async () => { + const res = await request(testApp).get("/protected").set("Authorization", "Bearer not.a.jwt"); - expect(res.status).toBe(401); - }); + expect(res.status).toBe(401); + }); }); -describe('errorHandler middleware', () => { - let loggerErrorSpy: jest.SpyInstance; +describe("errorHandler middleware", () => { + let loggerErrorSpy: jest.SpyInstance; - beforeEach(() => { - loggerErrorSpy = jest.spyOn(logger, 'error').mockImplementation(() => undefined); - }); + beforeEach(() => { + loggerErrorSpy = jest.spyOn(logger, "error").mockImplementation(() => undefined); + }); - afterEach(() => { - loggerErrorSpy.mockRestore(); - }); + afterEach(() => { + loggerErrorSpy.mockRestore(); + }); - it('should return 500 for unknown errors', async () => { - const errApp = express(); - errApp.use(express.json()); - errApp.get('/boom', () => { - throw new Error('Something broke'); - }); - errApp.use(errorHandler); + it("should return 500 for unknown errors", async () => { + const errApp = express(); + errApp.use(express.json()); + errApp.get("/boom", () => { + throw new Error("Something broke"); + }); + errApp.use(errorHandler); - const res = await request(errApp).get('/boom'); + const res = await request(errApp).get("/boom"); - expect(res.status).toBe(500); - expect(res.body.message).toBe('Internal server error'); - expect(loggerErrorSpy).toHaveBeenCalledWith(expect.any(Error)); - }); + expect(res.status).toBe(500); + expect(res.body.message).toBe("Internal server error"); + expect(loggerErrorSpy).toHaveBeenCalledWith(expect.any(Error)); + }); }); diff --git a/src/__tests__/middlewares/validate.test.ts b/src/__tests__/middlewares/validate.test.ts new file mode 100644 index 0000000..e95c201 --- /dev/null +++ b/src/__tests__/middlewares/validate.test.ts @@ -0,0 +1,53 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +import { validateBody } from "@api/middlewares/validate"; +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; + +describe("api/middlewares/validate", () => { + test("returns 400 when missing custom message", () => { + const schema = z.object({ + field: z.string(), + }); + + const req = { body: { field: 123 } } as Request; + const res = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + } as unknown as Response; + const next = jest.fn() as NextFunction; + + const middleware = validateBody(schema); + middleware(req, res, next); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + error: "Validation failed", + message: "Invalid input: expected string, received number", // zod default message + }); + }); + + test("uses fallback message if issue is undefined (edge case)", () => { + const mockSchema = { + safeParse: () => ({ success: false, error: { issues: [] } }), + } as any; + + const req = { body: {} } as Request; + const res = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + } as unknown as Response; + const next = jest.fn() as NextFunction; + + const middleware = validateBody(mockSchema); + middleware(req, res, next); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + error: "Validation failed", + message: "Invalid request body", + }); + }); +}); diff --git a/src/__tests__/mocks/prisma.ts b/src/__tests__/mocks/prisma.ts index 8f9c89c..a7e26b5 100644 --- a/src/__tests__/mocks/prisma.ts +++ b/src/__tests__/mocks/prisma.ts @@ -2,14 +2,21 @@ // Proprietary and confidential. Unauthorized use is strictly prohibited. // See LICENSE file in the project root for full license information. -const prisma = { +const prisma: Record = { user: { findUnique: jest.fn(), create: jest.fn(), update: jest.fn(), }, + account: { + findFirst: jest.fn(), + create: jest.fn(), + }, $connect: jest.fn(), $disconnect: jest.fn(), + $transaction: jest.fn(), }; +prisma.$transaction.mockImplementation((fn: Function) => fn(prisma)); + export default prisma; diff --git a/src/__tests__/queues/email.queue.test.ts b/src/__tests__/queues/email.queue.test.ts new file mode 100644 index 0000000..cbeac16 --- /dev/null +++ b/src/__tests__/queues/email.queue.test.ts @@ -0,0 +1,21 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +import { emailQueue } from "@queues/email.queue"; + +jest.mock("bullmq", () => { + return { + Queue: jest.fn().mockImplementation((name) => ({ name })), + }; +}); +jest.mock("@lib/redis", () => ({})); + +describe("queues/email.queue", () => { + test("exports email queue instance", () => { + // This just validates the queue definition runs without error + // and matches the mocked structure + expect(emailQueue).toBeDefined(); + expect(emailQueue.name).toBe("email"); + }); +}); diff --git a/src/__tests__/services/auth.service.test.ts b/src/__tests__/services/auth.service.test.ts index 2e1903a..bed9332 100644 --- a/src/__tests__/services/auth.service.test.ts +++ b/src/__tests__/services/auth.service.test.ts @@ -6,307 +6,385 @@ * @file Unit tests for auth.service and token utilities. */ -import crypto from 'crypto'; -import jwt from 'jsonwebtoken'; -import argon2 from 'argon2'; - -jest.mock('@lib/prisma', () => require('../mocks/prisma')); -jest.mock('@lib/redis', () => require('../mocks/redis')); -jest.mock('@config/env', () => ({ - env: { - JWT_SECRET: 'test-secret', - JWT_ACCESS_EXPIRY: '15m', - JWT_REFRESH_EXPIRY_SECONDS: 604800, - RESET_TOKEN_TTL_SECONDS: 900, - }, +import argon2 from "argon2"; +import crypto from "crypto"; +import jwt from "jsonwebtoken"; + +jest.mock("@lib/prisma", () => require("../mocks/prisma")); +jest.mock("@lib/redis", () => require("../mocks/redis")); +jest.mock("@queues/email.queue", () => ({ + emailQueue: { + add: jest.fn(), + }, +})); +jest.mock("@config/env", () => ({ + env: { + JWT_SECRET: "test-secret", + JWT_ACCESS_EXPIRY: "15m", + JWT_REFRESH_EXPIRY_SECONDS: 604800, + RESET_TOKEN_TTL_SECONDS: 3600, + FRONTEND_URL: "https://app.example.com", + }, })); -import prisma from '@lib/prisma'; -import redis from '@lib/redis'; -import * as authService from '@services/auth.service'; -import { verifyAccessToken } from '@utils/token'; -import { AUTH_MESSAGES } from '@constants/messages'; +import { AUTH_MESSAGES } from "@constants/messages"; +import prisma from "@lib/prisma"; +import redis from "@lib/redis"; +import * as authService from "@services/auth.service"; +import { verifyAccessToken } from "@utils/token"; +import { emailQueue } from "@queues/email.queue"; const mockPrisma = prisma as any; // eslint-disable-line @typescript-eslint/no-explicit-any const mockRedis = redis as any; // eslint-disable-line @typescript-eslint/no-explicit-any +const mockEmailQueue = emailQueue as any; // eslint-disable-line @typescript-eslint/no-explicit-any function hashToken(token: string): string { - return crypto.createHash('sha256').update(token).digest('hex'); + return crypto.createHash("sha256").update(token).digest("hex"); } -describe('authService.signup', () => { - it('should create a user with hashed password', async () => { - mockPrisma.user.findUnique.mockResolvedValue(null); - mockPrisma.user.create.mockResolvedValue({ - id: 'uuid-1', - email: 'a@b.com', - name: 'Test', - password: 'hashed', - createdAt: new Date(), - updatedAt: new Date(), - }); - - await authService.signup({ email: 'a@b.com', password: 'P@ssword1', name: 'Test' }); - - expect(mockPrisma.user.findUnique).toHaveBeenCalledWith({ where: { email: 'a@b.com' } }); - expect(mockPrisma.user.create).toHaveBeenCalledTimes(1); - - const createCall = mockPrisma.user.create.mock.calls[0][0]; - expect(createCall.data.password).not.toBe('P@ssword1'); - expect(createCall.data.password).toMatch(/^\$argon2/); +describe("authService.signup", () => { + it("should create a user with hashed password", async () => { + mockPrisma.user.findUnique.mockResolvedValue(null); + mockPrisma.user.create.mockResolvedValue({ + id: "uuid-1", + email: "a@b.com", + name: "Test", + password: "hashed", + createdAt: new Date(), + updatedAt: new Date(), }); - it('should throw ConflictError if email already exists', async () => { - mockPrisma.user.findUnique.mockResolvedValue({ - id: 'uuid-1', - email: 'a@b.com', - name: 'Existing', - password: 'hashed', - createdAt: new Date(), - updatedAt: new Date(), - }); - - await expect( - authService.signup({ email: 'a@b.com', password: 'P@ssword1', name: 'Test' }), - ).rejects.toThrow(AUTH_MESSAGES.EMAIL_TAKEN); - }); -}); + await authService.signup({ email: "a@b.com", password: "P@ssword1", name: "Test" }); -describe('authService.login', () => { - const hashedPw = argon2.hash('P@ssword1'); - - it('should return tokens and user info on valid credentials', async () => { - const pw = await hashedPw; - mockPrisma.user.findUnique.mockResolvedValue({ - id: 'uuid-1', - email: 'a@b.com', - name: 'Test', - password: pw, - createdAt: new Date(), - updatedAt: new Date(), - }); - mockRedis.set.mockResolvedValue('OK'); - - const result = await authService.login({ email: 'a@b.com', password: 'P@ssword1' }); - - expect(result.user).toEqual({ id: 'uuid-1', email: 'a@b.com', name: 'Test' }); - expect(result.tokens?.accessToken).toBeDefined(); - expect(result.tokens?.refreshToken).toBeDefined(); - - const decoded = jwt.verify(result.tokens!.accessToken, 'test-secret') as jwt.JwtPayload; - expect(decoded.sub).toBe('uuid-1'); - - expect(mockRedis.set).toHaveBeenCalledWith( - expect.stringContaining('refresh:uuid-1:'), - '1', - 'EX', - 604800, - ); - }); + expect(mockPrisma.user.findUnique).toHaveBeenCalledWith({ where: { email: "a@b.com" } }); + expect(mockPrisma.user.create).toHaveBeenCalledTimes(1); - it('should throw UnauthorizedError if user not found', async () => { - mockPrisma.user.findUnique.mockResolvedValue(null); + const createCall = mockPrisma.user.create.mock.calls[0][0]; + expect(createCall.data.password).not.toBe("P@ssword1"); + expect(createCall.data.password).toMatch(/^\$argon2/); + }); - await expect( - authService.login({ email: 'no@one.com', password: 'P@ssword1' }), - ).rejects.toThrow(AUTH_MESSAGES.INVALID_CREDENTIALS); + it("should throw ConflictError if email already exists", async () => { + mockPrisma.user.findUnique.mockResolvedValue({ + id: "uuid-1", + email: "a@b.com", + name: "Existing", + password: "hashed", + createdAt: new Date(), + updatedAt: new Date(), }); - it('should throw UnauthorizedError if password is wrong', async () => { - const pw = await hashedPw; - mockPrisma.user.findUnique.mockResolvedValue({ - id: 'uuid-1', - email: 'a@b.com', - name: 'Test', - password: pw, - createdAt: new Date(), - updatedAt: new Date(), - }); - - await expect( - authService.login({ email: 'a@b.com', password: 'WrongPassword' }), - ).rejects.toThrow(AUTH_MESSAGES.INVALID_CREDENTIALS); - }); + await expect(authService.signup({ email: "a@b.com", password: "P@ssword1", name: "Test" })).rejects.toThrow( + AUTH_MESSAGES.EMAIL_TAKEN, + ); + }); }); -describe('authService.refresh', () => { - it('should rotate tokens and return new pair', async () => { - const rawToken = 'old-refresh-token'; - const hashed = hashToken(rawToken); - const key = `refresh:uuid-1:${hashed}`; - - mockRedis.scan.mockResolvedValueOnce(['0', [key]]); - mockRedis.del.mockResolvedValue(1); - mockRedis.set.mockResolvedValue('OK'); - - const result = await authService.refresh(rawToken); - - expect(result.tokens?.accessToken).toBeDefined(); - expect(result.tokens?.refreshToken).toBeDefined(); - expect(mockRedis.del).toHaveBeenCalledWith(key); - expect(mockRedis.set).toHaveBeenCalledWith( - expect.stringContaining('refresh:uuid-1:'), - '1', - 'EX', - 604800, - ); +describe("authService.login", () => { + const hashedPw = argon2.hash("P@ssword1"); + + it("should return tokens and user info on valid credentials", async () => { + const pw = await hashedPw; + mockPrisma.user.findUnique.mockResolvedValue({ + id: "uuid-1", + email: "a@b.com", + name: "Test", + password: pw, + createdAt: new Date(), + updatedAt: new Date(), }); + mockRedis.set.mockResolvedValue("OK"); + + const result = await authService.login({ email: "a@b.com", password: "P@ssword1" }); + + expect(result.user).toEqual({ id: "uuid-1", email: "a@b.com", name: "Test" }); + expect(result.tokens?.accessToken).toBeDefined(); + expect(result.tokens?.refreshToken).toBeDefined(); + + const decoded = jwt.verify(result.tokens!.accessToken, "test-secret") as jwt.JwtPayload; + expect(decoded.sub).toBe("uuid-1"); - it('should throw UnauthorizedError if refresh token not found in Redis', async () => { - mockRedis.scan.mockResolvedValue(['0', []]); + expect(mockRedis.set).toHaveBeenCalledWith(expect.stringContaining("refresh:uuid-1:"), "1", "EX", 604800); + }); - await expect(authService.refresh('bad-token')).rejects.toThrow( - AUTH_MESSAGES.REFRESH_TOKEN_INVALID, - ); + it("should throw UnauthorizedError if user not found", async () => { + mockPrisma.user.findUnique.mockResolvedValue(null); + + await expect(authService.login({ email: "no@one.com", password: "P@ssword1" })).rejects.toThrow( + AUTH_MESSAGES.INVALID_CREDENTIALS, + ); + }); + + it("should throw UnauthorizedError if password is wrong", async () => { + const pw = await hashedPw; + mockPrisma.user.findUnique.mockResolvedValue({ + id: "uuid-1", + email: "a@b.com", + name: "Test", + password: pw, + createdAt: new Date(), + updatedAt: new Date(), }); + + await expect(authService.login({ email: "a@b.com", password: "WrongPassword" })).rejects.toThrow( + AUTH_MESSAGES.INVALID_CREDENTIALS, + ); + }); }); -describe('authService.logout', () => { - it('should delete the refresh token from Redis', async () => { - const rawToken = 'some-refresh-token'; - const hashed = hashToken(rawToken); - const key = `refresh:uuid-1:${hashed}`; +describe("authService.refresh", () => { + it("should rotate tokens and return new pair", async () => { + const rawToken = "old-refresh-token"; + const hashed = hashToken(rawToken); + const key = `refresh:uuid-1:${hashed}`; - mockRedis.scan.mockResolvedValueOnce(['0', [key]]); - mockRedis.del.mockResolvedValue(1); + mockRedis.scan.mockResolvedValueOnce(["0", [key]]); + mockRedis.del.mockResolvedValue(1); + mockRedis.set.mockResolvedValue("OK"); - await authService.logout(rawToken); + const result = await authService.refresh(rawToken); - expect(mockRedis.del).toHaveBeenCalledWith(key); - }); + expect(result.tokens?.accessToken).toBeDefined(); + expect(result.tokens?.refreshToken).toBeDefined(); + expect(mockRedis.del).toHaveBeenCalledWith(key); + expect(mockRedis.set).toHaveBeenCalledWith(expect.stringContaining("refresh:uuid-1:"), "1", "EX", 604800); + }); - it('should be a no-op if token not found in Redis', async () => { - mockRedis.scan.mockResolvedValue(['0', []]); + it("should throw UnauthorizedError if refresh token not found in Redis", async () => { + mockRedis.scan.mockResolvedValue(["0", []]); - await expect(authService.logout('unknown-token')).resolves.toMatchObject({ message: AUTH_MESSAGES.LOGOUT_SUCCESS }); - }); + await expect(authService.refresh("bad-token")).rejects.toThrow(AUTH_MESSAGES.REFRESH_TOKEN_INVALID); + }); }); -describe('authService.forgotPassword', () => { - it('should store a hashed reset token in Redis if user exists', async () => { - mockPrisma.user.findUnique.mockResolvedValue({ - id: 'uuid-1', - email: 'a@b.com', - name: 'Test', - password: 'hashed', - createdAt: new Date(), - updatedAt: new Date(), - }); - mockRedis.set.mockResolvedValue('OK'); - - await authService.forgotPassword({ email: 'a@b.com' }); - - expect(mockRedis.set).toHaveBeenCalledWith( - expect.stringContaining('reset:'), - 'uuid-1', - 'EX', - 900, - ); - }); +describe("authService.logout", () => { + it("should delete the refresh token from Redis", async () => { + const rawToken = "some-refresh-token"; + const hashed = hashToken(rawToken); + const key = `refresh:uuid-1:${hashed}`; + + mockRedis.scan.mockResolvedValueOnce(["0", [key]]); + mockRedis.del.mockResolvedValue(1); + + await authService.logout(rawToken); - it('should do nothing (no throw) if user does not exist', async () => { - mockPrisma.user.findUnique.mockResolvedValue(null); + expect(mockRedis.del).toHaveBeenCalledWith(key); + }); - await expect(authService.forgotPassword({ email: 'no@one.com' })).resolves.toBeUndefined(); - expect(mockRedis.set).not.toHaveBeenCalled(); + it("should be a no-op if token not found in Redis", async () => { + mockRedis.scan.mockResolvedValue(["0", []]); + + await expect(authService.logout("unknown-token")).resolves.toMatchObject({ message: AUTH_MESSAGES.LOGOUT_SUCCESS }); + }); +}); + +describe("authService.forgotPassword", () => { + it("should store a hashed reset token in Redis and enqueue email when user has a password", async () => { + mockPrisma.user.findUnique.mockResolvedValue({ + id: "uuid-1", + email: "a@b.com", + name: "Test", + password: "hashed", + createdAt: new Date(), + updatedAt: new Date(), + }); + mockRedis.set.mockResolvedValue("OK"); + + await authService.forgotPassword({ email: "a@b.com" }); + + expect(mockRedis.set).toHaveBeenCalledWith(expect.stringContaining("reset:"), "uuid-1", "EX", 3600); + + expect(mockEmailQueue.add).toHaveBeenCalledWith( + "send-reset-email", + expect.objectContaining({ + userId: "uuid-1", + email: "a@b.com", + name: "Test", + resetUrl: expect.stringContaining("https://app.example.com/reset-password?token="), + }), + ); + }); + + it("should store a hashed reset token in Redis and enqueue email when use has no password", async () => { + mockPrisma.user.findUnique.mockResolvedValue({ + id: "uuid-1", + email: "oauth@b.com", + name: "OAuth User", + password: null, + createdAt: new Date(), + updatedAt: new Date(), }); + + await authService.forgotPassword({ email: "oauth@b.com" }); + + expect(mockRedis.set).toHaveBeenCalledWith(expect.stringContaining("reset:"), "uuid-1", "EX", 3600); + expect(mockEmailQueue.add).toHaveBeenCalledWith( + "send-reset-email", + expect.objectContaining({ + userId: "uuid-1", + email: "oauth@b.com", + name: "OAuth User", + resetUrl: expect.stringContaining("https://app.example.com/reset-password?token="), + }), + ); + }); + + it("should do nothing (no throw) if user does not exist", async () => { + mockPrisma.user.findUnique.mockResolvedValue(null); + + await expect(authService.forgotPassword({ email: "no@one.com" })).resolves.toBeUndefined(); + expect(mockRedis.set).not.toHaveBeenCalled(); + }); }); -describe('authService.resetPassword', () => { - it('should update password, delete reset token, and purge all refresh tokens', async () => { - const rawToken = 'reset-token-raw'; - const hashed = hashToken(rawToken); - - mockRedis.get.mockResolvedValue('uuid-1'); - mockPrisma.user.update.mockResolvedValue({ - id: 'uuid-1', - email: 'a@b.com', - name: 'Test', - password: 'new-hashed', - createdAt: new Date(), - updatedAt: new Date(), - }); - mockRedis.del.mockResolvedValue(1); - mockRedis.scan.mockResolvedValueOnce(['0', ['refresh:uuid-1:abc', 'refresh:uuid-1:def']]); - - await authService.resetPassword({ token: rawToken, newPassword: 'NewP@ss1' }); - - expect(mockRedis.get).toHaveBeenCalledWith(`reset:${hashed}`); - expect(mockPrisma.user.update).toHaveBeenCalledWith({ - where: { id: 'uuid-1' }, - data: { password: expect.stringMatching(/^\$argon2/) }, - }); - expect(mockRedis.del).toHaveBeenCalledWith(`reset:${hashed}`); - expect(mockRedis.del).toHaveBeenCalledWith('refresh:uuid-1:abc', 'refresh:uuid-1:def'); +describe("authService.resetPassword", () => { + it("should update password, delete reset token from Redis, and purge all refresh tokens", async () => { + const rawToken = "reset-token-raw"; + const hashed = hashToken(rawToken); + + mockRedis.get.mockResolvedValue("uuid-1"); + mockPrisma.user.update.mockResolvedValue({ + id: "uuid-1", + email: "a@b.com", + name: "Test", + password: "new-hashed", + createdAt: new Date(), + updatedAt: new Date(), }); + mockRedis.del.mockResolvedValue(1); + mockRedis.scan.mockResolvedValueOnce(["0", ["refresh:uuid-1:abc", "refresh:uuid-1:def"]]); - it('should throw BadRequestError if reset token not found in Redis', async () => { - mockRedis.get.mockResolvedValue(null); + await authService.resetPassword({ token: rawToken, newPassword: "NewP@ss1" }); - await expect( - authService.resetPassword({ token: 'expired-token', newPassword: 'NewP@ss1' }), - ).rejects.toThrow(AUTH_MESSAGES.RESET_TOKEN_INVALID); + expect(mockRedis.get).toHaveBeenCalledWith(`reset:${hashed}`); + expect(mockPrisma.user.update).toHaveBeenCalledWith({ + where: { id: "uuid-1" }, + data: { password: expect.stringMatching(/^\$argon2/) }, }); + expect(mockRedis.del).toHaveBeenCalledWith(`reset:${hashed}`); + expect(mockRedis.del).toHaveBeenCalledWith("refresh:uuid-1:abc", "refresh:uuid-1:def"); + }); + + it("should throw BadRequestError if reset token not found in Redis", async () => { + mockRedis.get.mockResolvedValue(null); + + await expect(authService.resetPassword({ token: "bad-token", newPassword: "NewP@ss1" })).rejects.toThrow( + AUTH_MESSAGES.RESET_TOKEN_INVALID, + ); + }); +}); + +describe("verifyAccessToken", () => { + it("should return userId from valid token", () => { + const token = jwt.sign({ sub: "uuid-1" }, "test-secret", { expiresIn: "15m" }); + expect(verifyAccessToken(token)).toBe("uuid-1"); + }); + + it("should throw UnauthorizedError for expired token", () => { + const token = jwt.sign({ sub: "uuid-1" }, "test-secret", { expiresIn: "-1s" }); + expect(() => verifyAccessToken(token)).toThrow(); + }); + + it("should throw UnauthorizedError for invalid signature", () => { + const token = jwt.sign({ sub: "uuid-1" }, "wrong-secret"); + expect(() => verifyAccessToken(token)).toThrow(); + }); + + it("should throw UnauthorizedError for token without sub", () => { + const token = jwt.sign({ foo: "bar" }, "test-secret"); + expect(() => verifyAccessToken(token)).toThrow("Malformed token"); + }); }); -describe('verifyAccessToken', () => { - it('should return userId from valid token', () => { - const token = jwt.sign({ sub: 'uuid-1' }, 'test-secret', { expiresIn: '15m' }); - expect(verifyAccessToken(token)).toBe('uuid-1'); +describe("authService.oauthLogin", () => { + it("should create a new user and account when none exists and return tokens", async () => { + mockPrisma.user.findUnique.mockResolvedValue(null); + mockPrisma.user.create.mockResolvedValue({ + id: "uuid-2", + email: "oauth@user.com", + name: "OAuth User", + createdAt: new Date(), + updatedAt: new Date(), + }); + mockPrisma.account.create.mockResolvedValue({ + id: "acc-1", + userId: "uuid-2", + provider: "google", + providerAccountId: "google-id-123", }); + mockRedis.set.mockResolvedValue("OK"); - it('should throw UnauthorizedError for expired token', () => { - const token = jwt.sign({ sub: 'uuid-1' }, 'test-secret', { expiresIn: '-1s' }); - expect(() => verifyAccessToken(token)).toThrow(); + const res = await authService.oauthLogin("google", { + email: "oauth@user.com", + name: "OAuth User", + providerAccountId: "google-id-123", }); - it('should throw UnauthorizedError for invalid signature', () => { - const token = jwt.sign({ sub: 'uuid-1' }, 'wrong-secret'); - expect(() => verifyAccessToken(token)).toThrow(); + expect(mockPrisma.$transaction).toHaveBeenCalled(); + expect(mockPrisma.user.create).toHaveBeenCalled(); + expect(mockPrisma.account.create).toHaveBeenCalled(); + expect(res.user).toEqual({ id: "uuid-2", email: "oauth@user.com", name: "OAuth User" }); + expect(res.tokens?.accessToken).toBeDefined(); + expect(res.tokens?.refreshToken).toBeDefined(); + expect(mockRedis.set).toHaveBeenCalledWith(expect.stringContaining("refresh:uuid-2:"), "1", "EX", 604800); + }); + + it("should link a new account when user exists but has no account for this provider", async () => { + mockPrisma.user.findUnique.mockResolvedValue({ + id: "uuid-3", + email: "exist@user.com", + name: "Existing", }); + mockPrisma.account.findFirst.mockResolvedValue(null); + mockPrisma.account.create.mockResolvedValue({ + id: "acc-2", + userId: "uuid-3", + provider: "google", + providerAccountId: "google-id-456", + }); + mockRedis.set.mockResolvedValue("OK"); - it('should throw UnauthorizedError for token without sub', () => { - const token = jwt.sign({ foo: 'bar' }, 'test-secret'); - expect(() => verifyAccessToken(token)).toThrow('Malformed token'); + const res = await authService.oauthLogin("google", { + email: "exist@user.com", + name: "Existing", + providerAccountId: "google-id-456", }); -}); -describe('authService.oauthLogin', () => { - it('should create a new user when none exists and return tokens', async () => { - mockPrisma.user.findUnique.mockResolvedValue(null); - mockPrisma.user.create.mockResolvedValue({ - id: 'uuid-2', - email: 'oauth@user.com', - name: 'OAuth User', - provider: 'google', - createdAt: new Date(), - updatedAt: new Date(), - }); - mockRedis.set.mockResolvedValue('OK'); - - const res = await authService.oauthLogin('google', { email: 'oauth@user.com', name: 'OAuth User' }); - - expect(mockPrisma.user.create).toHaveBeenCalled(); - expect(res.user).toEqual({ id: 'uuid-2', email: 'oauth@user.com', name: 'OAuth User' }); - expect(res.tokens?.accessToken).toBeDefined(); - expect(res.tokens?.refreshToken).toBeDefined(); - expect(mockRedis.set).toHaveBeenCalledWith(expect.stringContaining('refresh:uuid-2:'), '1', 'EX', 604800); + expect(mockPrisma.user.create).not.toHaveBeenCalled(); + expect(mockPrisma.account.findFirst).toHaveBeenCalledWith({ + where: { userId: "uuid-3", provider: "google" }, }); + expect(mockPrisma.account.create).toHaveBeenCalledWith({ + data: { userId: "uuid-3", provider: "google", providerAccountId: "google-id-456" }, + }); + expect(res.user).toEqual({ id: "uuid-3", email: "exist@user.com", name: "Existing" }); + expect(res.tokens?.accessToken).toBeDefined(); + }); + + it("should just log in when user and account already exist", async () => { + mockPrisma.user.findUnique.mockResolvedValue({ + id: "uuid-3", + email: "exist@user.com", + name: "Existing", + }); + mockPrisma.account.findFirst.mockResolvedValue({ + id: "acc-2", + userId: "uuid-3", + provider: "google", + providerAccountId: "google-id-456", + }); + mockRedis.set.mockResolvedValue("OK"); - it('should use existing user when found and return tokens', async () => { - mockPrisma.user.findUnique.mockResolvedValue({ - id: 'uuid-3', - email: 'exist@user.com', - name: 'Existing', - }); - mockRedis.set.mockResolvedValue('OK'); - - const res = await authService.oauthLogin('google', { email: 'exist@user.com', name: 'Existing' }); - - expect(mockPrisma.user.create).not.toHaveBeenCalled(); - expect(res.user).toEqual({ id: 'uuid-3', email: 'exist@user.com', name: 'Existing' }); - expect(res.tokens?.accessToken).toBeDefined(); - expect(res.tokens?.refreshToken).toBeDefined(); - expect(mockRedis.set).toHaveBeenCalledWith(expect.stringContaining('refresh:uuid-3:'), '1', 'EX', 604800); + const res = await authService.oauthLogin("google", { + email: "exist@user.com", + name: "Existing", + providerAccountId: "google-id-456", }); + + expect(mockPrisma.user.create).not.toHaveBeenCalled(); + expect(mockPrisma.account.create).not.toHaveBeenCalled(); + expect(res.user).toEqual({ id: "uuid-3", email: "exist@user.com", name: "Existing" }); + expect(res.tokens?.accessToken).toBeDefined(); + expect(res.tokens?.refreshToken).toBeDefined(); + }); }); diff --git a/src/__tests__/services/email.service.test.ts b/src/__tests__/services/email.service.test.ts new file mode 100644 index 0000000..08a585f --- /dev/null +++ b/src/__tests__/services/email.service.test.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +const mockSend = jest.fn(); +jest.mock("resend", () => ({ + Resend: jest.fn().mockImplementation(() => ({ + emails: { send: mockSend }, + })), +})); + +jest.mock("@config/env", () => ({ + env: { + RESEND_API_KEY: "test-key", + RESET_PASSWORD_EMAIL: "support@x.com", + RESET_PASSWORD_TEMPLATE_ID: "template-1" + } +})); + +import { sendResetEmail } from "@services/email.service"; +import { logger } from "@services/logger.service"; + +jest.spyOn(logger, "info").mockImplementation(); +jest.spyOn(logger, "error").mockImplementation(); + +describe("services/email.service", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("logs error if Resend fails", async () => { + mockSend.mockResolvedValueOnce({ error: { message: "API failure" } }); + await sendResetEmail("test@x.com", "http://reset", "Tester"); + expect(logger.error).toHaveBeenCalled(); + }); + + test("logs success if Resend succeeds", async () => { + mockSend.mockResolvedValueOnce({ data: { id: "msg-123" }, error: null }); + await sendResetEmail("test@x.com", "http://reset", "Tester"); + expect(logger.info).toHaveBeenCalledTimes(2); // info and structural info + }); +}); diff --git a/src/__tests__/services/logger.service.test.ts b/src/__tests__/services/logger.service.test.ts new file mode 100644 index 0000000..ef8e58e --- /dev/null +++ b/src/__tests__/services/logger.service.test.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +describe("services/logger.service", () => { + const originalEnv = process.env.NODE_ENV; + + afterEach(() => { + process.env.NODE_ENV = originalEnv; + jest.resetModules(); + }); + + test("uses info level when in production", async () => { + process.env.NODE_ENV = "production"; + const { logger } = await import("@services/logger.service"); + expect(logger.level).toBe("info"); + }); + + test("uses debug level when not in production", async () => { + process.env.NODE_ENV = "development"; + const { logger } = await import("@services/logger.service"); + expect(logger.level).toBe("debug"); + }); +}); diff --git a/src/__tests__/services/oauth.service.test.ts b/src/__tests__/services/oauth.service.test.ts index e12088f..c48a749 100644 --- a/src/__tests__/services/oauth.service.test.ts +++ b/src/__tests__/services/oauth.service.test.ts @@ -60,25 +60,34 @@ describe('oauth.service', () => { test('exchangeCodeForProfile returns google profile on success', async () => { fetchSpy = jest.spyOn(global as any, 'fetch') .mockImplementationOnce(async () => ({ ok: true, json: async () => ({ access_token: 'tok' }) })) - .mockImplementationOnce(async () => ({ ok: true, json: async () => ({ email: 'a@example.com', name: 'Alice' }) })); + .mockImplementationOnce(async () => ({ ok: true, json: async () => ({ email: 'a@example.com', name: 'Alice', id: 'google-123' }) })); const profile = await exchangeCodeForProfile('google', 'code'); expect(profile.email).toBe('a@example.com'); expect(profile.name).toBe('Alice'); + expect(profile.providerAccountId).toBe('google-123'); }); test('exchangeCodeForProfile returns github profile using emails endpoint when necessary', async () => { fetchSpy = jest.spyOn(global as any, 'fetch') .mockImplementationOnce(async () => ({ ok: true, json: async () => ({ access_token: 'gh-token' }) })) - .mockImplementationOnce(async () => ({ ok: true, json: async () => ({ email: null, login: 'octocat', name: null }) })) + .mockImplementationOnce(async () => ({ ok: true, json: async () => ({ email: null, login: 'octocat', name: null, id: 12345 }) })) .mockImplementationOnce(async () => ({ ok: true, json: async () => ([{ email: 'gh@example.com', primary: true, verified: true }]) })); const profile = await exchangeCodeForProfile('github', 'code'); expect(profile.email).toBe('gh@example.com'); expect(profile.name).toBe('octocat'); + expect(profile.providerAccountId).toBe('12345'); }); test('exchangeCodeForProfile throws when google userinfo endpoint fails', async () => { + fetchSpy = jest.spyOn(global as any, 'fetch') + .mockImplementationOnce(async () => ({ ok: true, json: async () => ({ token_type: 'bearer' }) })); + + await expect(exchangeCodeForProfile('google', 'code')).rejects.toThrow(BadRequestError); + }); + + test('exchangeCodeForProfile throws when google userinfo endpoint fails2', async () => { fetchSpy = jest.spyOn(global as any, 'fetch') .mockImplementationOnce(async () => ({ ok: true, json: async () => ({ access_token: 'tok' }) })) .mockImplementationOnce(async () => ({ ok: false })); diff --git a/src/__tests__/utils/errors.test.ts b/src/__tests__/utils/errors.test.ts new file mode 100644 index 0000000..c628c3c --- /dev/null +++ b/src/__tests__/utils/errors.test.ts @@ -0,0 +1,44 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +import { AppError, UnauthorizedError, BadRequestError, ConflictError } from "@utils/errors"; + +describe("utils/errors", () => { + test("AppError", () => { + const err = new AppError(418, "I am a teapot"); + expect(err.statusCode).toBe(418); + expect(err.message).toBe("I am a teapot"); + expect(err.name).toBe("AppError"); + }); + + test("UnauthorizedError", () => { + const err = new UnauthorizedError(); + expect(err.statusCode).toBe(401); + expect(err.message).toBe("Unauthorized"); + expect(err.name).toBe("UnauthorizedError"); + + const customErr = new UnauthorizedError("Custom message"); + expect(customErr.message).toBe("Custom message"); + }); + + test("BadRequestError", () => { + const err = new BadRequestError(); + expect(err.statusCode).toBe(400); + expect(err.message).toBe("Bad request"); + expect(err.name).toBe("BadRequestError"); + + const customErr = new BadRequestError("Custom msg"); + expect(customErr.message).toBe("Custom msg"); + }); + + test("ConflictError", () => { + const err = new ConflictError(); + expect(err.statusCode).toBe(409); + expect(err.message).toBe("Conflict"); + expect(err.name).toBe("ConflictError"); + + const customErr = new ConflictError("Custom conflict"); + expect(customErr.message).toBe("Custom conflict"); + }); +}); diff --git a/src/__tests__/utils/redirect.test.ts b/src/__tests__/utils/redirect.test.ts new file mode 100644 index 0000000..617f7d5 --- /dev/null +++ b/src/__tests__/utils/redirect.test.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +import { buildRedirectUrl } from "@utils/redirect"; + +describe("utils/redirect", () => { + test("returns pathname if no baseUrl", () => { + expect(buildRedirectUrl("", "/path")).toBe("/path"); + }); + + test("returns empty string if no baseUrl and no pathname", () => { + expect(buildRedirectUrl("")).toBe(""); + }); + + test("handles baseUrl with trailing slashes and pathname with leading slashes", () => { + expect(buildRedirectUrl("http://example.com///", "///path///")).toBe("http://example.com/path///"); + }); + + test("handles URLSearchParams correctly", () => { + const params = new URLSearchParams({ a: "1", b: "2" }); + expect(buildRedirectUrl("http://example.com", "/path", params)).toBe("http://example.com/path?a=1&b=2"); + }); + + test("handles Record payload correctly", () => { + expect(buildRedirectUrl("http://example.com", "/path", { foo: "bar", baz: "qux" })).toBe( + "http://example.com/path?foo=bar&baz=qux", + ); + }); + + test("appends search only if it exists", () => { + expect(buildRedirectUrl("http://x.com", "/y", {})).toBe("http://x.com/y"); + expect(buildRedirectUrl("http://x.com", "/y", new URLSearchParams())).toBe("http://x.com/y"); + }); +}); diff --git a/src/__tests__/workers/email.worker.test.ts b/src/__tests__/workers/email.worker.test.ts new file mode 100644 index 0000000..be0d7e3 --- /dev/null +++ b/src/__tests__/workers/email.worker.test.ts @@ -0,0 +1,53 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +import { Worker } from "bullmq"; +import * as emailService from "@services/email.service"; + +let workerCallback: any; + +jest.mock("bullmq", () => { + return { + Worker: jest.fn().mockImplementation((name, cb) => { + workerCallback = cb; + return {}; + }), + }; +}); + +jest.mock("@config/env", () => ({ + env: { RESEND_API_KEY: "test-key" } +})); + +jest.mock("@services/email.service"); +jest.mock("@services/logger.service", () => ({ logger: { info: jest.fn() } })); +jest.mock("@lib/redis", () => ({ workerRedis: {} })); + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import "@workers/email.worker"; + +describe("workers/email.worker", () => { + test("workerCallback delegates send-reset-email job", async () => { + expect(workerCallback).toBeDefined(); + + const job = { + name: "send-reset-email", + data: { email: "u@u.com", resetUrl: "link", name: "U" }, + }; + + await workerCallback(job); + expect(emailService.sendResetEmail).toHaveBeenCalledWith("u@u.com", "link", "U"); + }); + + test("workerCallback ignores other job names", async () => { + jest.clearAllMocks(); + const job = { + name: "other-job", + data: {}, + }; + + await workerCallback(job); + expect(emailService.sendResetEmail).not.toHaveBeenCalled(); + }); +}); diff --git a/src/api/controllers/auth.controller.ts b/src/api/controllers/auth.controller.ts index 0c18dd2..e33006b 100644 --- a/src/api/controllers/auth.controller.ts +++ b/src/api/controllers/auth.controller.ts @@ -11,6 +11,7 @@ import { AUTH_MESSAGES } from '@constants/messages'; import { VALID_PROVIDERS } from '@constants/auth'; import { StatusCodes } from 'http-status-codes'; import { env } from '@config/env'; +import { buildRedirectUrl } from '@utils/redirect'; export async function signup(req: Request, res: Response, next: NextFunction): Promise { @@ -110,7 +111,7 @@ export async function oauthCallback(req: Request, res: Response, next: NextFunct const msg = oauthError === 'access_denied' ? AUTH_MESSAGES.OAUTH_CANCELLED : AUTH_MESSAGES.OAUTH_CODE_MISSING; - const errorRedirect = `${env.OAUTH_FRONTEND_URL}/login?error=${encodeURIComponent(msg)}`; + const errorRedirect = buildRedirectUrl(env.FRONTEND_URL, '/login', { error: msg }); res.redirect(errorRedirect); return; } @@ -127,10 +128,11 @@ export async function oauthCallback(req: Request, res: Response, next: NextFunct name: loginResponse.user!.name, }); - res.redirect(`${env.OAUTH_FRONTEND_URL}/oauth/callback?${params.toString()}`); + const redirectUrl = buildRedirectUrl(env.FRONTEND_URL, '/oauth/callback', params); + res.redirect(redirectUrl); } catch (err) { const message = err instanceof Error ? err.message : 'OAuth login failed'; - const errorRedirect = `${env.OAUTH_FRONTEND_URL}/login?error=${encodeURIComponent(message)}`; + const errorRedirect = buildRedirectUrl(env.FRONTEND_URL, '/login', { error: message }); res.redirect(errorRedirect); } } diff --git a/src/app.ts b/src/app.ts index 374739e..177562e 100644 --- a/src/app.ts +++ b/src/app.ts @@ -12,16 +12,25 @@ import { swaggerSpec } from '@config/swagger'; import authRoutes from '@api/routes/auth.routes'; import { errorHandler } from '@api/middlewares/errorHandler'; import { httpLogger } from '@api/middlewares/logger'; +import "@workers/email.worker" const app: Application = express(); app.use(helmet()); -app.use( - cors({ - origin: env.CORS_ORIGINS, - credentials: true, - }), -); + +const allowedOrigins = env.CORS_ORIGINS; +const corsOptions = { + origin: (requestOrigin: string | undefined, callback: (err: Error | null, allow?: boolean) => void) => { + if (!requestOrigin) return callback(null, true); + if (allowedOrigins.includes('*') || allowedOrigins.includes(requestOrigin)) return callback(null, true); + return callback(new Error('Not allowed by CORS')); + }, + credentials: env.CORS_CREDENTIALS === 'true', + optionsSuccessStatus: 200, +}; +app.use(cors(corsOptions)); +app.options('*', cors(corsOptions)); + app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use(httpLogger); diff --git a/src/config/env.ts b/src/config/env.ts index 85ca2a5..93fac80 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -3,23 +3,53 @@ // See LICENSE file in the project root for full license information. export const env = { - NODE_ENV: process.env.NODE_ENV ?? 'development', - PORT: parseInt(process.env.PORT ?? '3000', 10), - DATABASE_URL: process.env.DATABASE_URL ?? '', - REDIS_URL: process.env.REDIS_URL ?? 'redis://localhost:6379', - CORS_ORIGINS: (process.env.CORS_ORIGINS ?? 'http://localhost:5173').split(','), - JWT_SECRET: process.env.JWT_SECRET ?? '', - JWT_ACCESS_EXPIRY: process.env.JWT_ACCESS_EXPIRY ?? '15m', - JWT_REFRESH_EXPIRY_SECONDS: parseInt(process.env.JWT_REFRESH_EXPIRY_SECONDS ?? '604800', 10), // 7 days - RESET_TOKEN_TTL_SECONDS: parseInt(process.env.RESET_TOKEN_TTL_SECONDS ?? '900', 10), // 15 min - API_PREFIX: process.env.API_PREFIX ?? '/api/v1', + NODE_ENV: process.env.NODE_ENV ?? "development", + PORT: parseInt(process.env.PORT ?? "3000", 10), + DATABASE_URL: process.env.DATABASE_URL ?? "", + REDIS_URL: process.env.REDIS_URL ?? "redis://localhost:6379", + CORS_ORIGINS: (process.env.CORS_ORIGINS ?? "http://localhost:5173").split(","), + CORS_CREDENTIALS: process.env.CORS_CREDENTIALS ?? "true", + JWT_SECRET: process.env.JWT_SECRET ?? "", + JWT_ACCESS_EXPIRY: process.env.JWT_ACCESS_EXPIRY ?? "15m", + JWT_REFRESH_EXPIRY_SECONDS: parseInt(process.env.JWT_REFRESH_EXPIRY_SECONDS ?? "604800", 10), // 7 days + RESET_TOKEN_TTL_SECONDS: parseInt(process.env.RESET_TOKEN_TTL_SECONDS ?? "900", 10), // 15 min + API_PREFIX: process.env.API_PREFIX ?? "/api/v1", - // OAuth - OAUTH_FRONTEND_URL: process.env.OAUTH_FRONTEND_URL ?? 'http://localhost:5173', - GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID ?? '', - GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET ?? '', - GOOGLE_CALLBACK_URL: process.env.GOOGLE_CALLBACK_URL ?? 'http://localhost:3000/api/v1/auth/oauth/google/callback', - GITHUB_CLIENT_ID: process.env.GITHUB_CLIENT_ID ?? '', - GITHUB_CLIENT_SECRET: process.env.GITHUB_CLIENT_SECRET ?? '', - GITHUB_CALLBACK_URL: process.env.GITHUB_CALLBACK_URL ?? 'http://localhost:3000/api/v1/auth/oauth/github/callback', -} as const; \ No newline at end of file + // OAuth + FRONTEND_URL: process.env.FRONTEND_URL ?? "http://localhost:5173", + GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID ?? "", + GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET ?? "", + GOOGLE_CALLBACK_URL: process.env.GOOGLE_CALLBACK_URL ?? "http://localhost:3000/api/v1/auth/oauth/google/callback", + GITHUB_CLIENT_ID: process.env.GITHUB_CLIENT_ID ?? "", + GITHUB_CLIENT_SECRET: process.env.GITHUB_CLIENT_SECRET ?? "", + GITHUB_CALLBACK_URL: process.env.GITHUB_CALLBACK_URL ?? "http://localhost:3000/api/v1/auth/oauth/github/callback", + + // Emails + RESEND_API_KEY: process.env.RESEND_API_KEY ?? "", + RESET_PASSWORD_EMAIL: process.env.RESET_PASSWORD_EMAIL ?? "Coverit ", + RESET_PASSWORD_TEMPLATE_ID: process.env.RESET_PASSWORD_TEMPLATE_ID ?? "", +} as const; + +console.info("Loaded environment variables:", { + NODE_ENV: env.NODE_ENV, + PORT: env.PORT, + DATABASE_URL: env.DATABASE_URL ? "****" : "(not set)", + REDIS_URL: env.REDIS_URL ? "****" : "(not set)", + CORS_ORIGINS: env.CORS_ORIGINS, + CORS_CREDENTIALS: env.CORS_CREDENTIALS, + JWT_SECRET: env.JWT_SECRET ? "****" : "(not set)", + JWT_ACCESS_EXPIRY: env.JWT_ACCESS_EXPIRY, + JWT_REFRESH_EXPIRY_SECONDS: env.JWT_REFRESH_EXPIRY_SECONDS, + RESET_TOKEN_TTL_SECONDS: env.RESET_TOKEN_TTL_SECONDS, + API_PREFIX: env.API_PREFIX, + FRONTEND_URL: env.FRONTEND_URL, + GOOGLE_CLIENT_ID: env.GOOGLE_CLIENT_ID ? "****" : "(not set)", + GOOGLE_CLIENT_SECRET: env.GOOGLE_CLIENT_SECRET ? "****" : "(not set)", + GOOGLE_CALLBACK_URL: env.GOOGLE_CALLBACK_URL, + GITHUB_CLIENT_ID: env.GITHUB_CLIENT_ID ? "****" : "(not set)", + GITHUB_CLIENT_SECRET: env.GITHUB_CLIENT_SECRET ? "****" : "(not set)", + GITHUB_CALLBACK_URL: env.GITHUB_CALLBACK_URL, + RESEND_API_KEY: env.RESEND_API_KEY ? "****" : "(not set)", + RESET_PASSWORD_EMAIL: env.RESET_PASSWORD_EMAIL, + RESET_PASSWORD_TEMPLATE_ID: env.RESET_PASSWORD_TEMPLATE_ID ? "****" : "(not set)", +}); \ No newline at end of file diff --git a/src/config/openapi/auth.ts b/src/config/openapi/auth.ts index e2db99d..8621f3c 100644 --- a/src/config/openapi/auth.ts +++ b/src/config/openapi/auth.ts @@ -105,6 +105,6 @@ registry.registerPath({ request: { body: { content: { 'application/json': { schema: ResetPasswordRequestSchema } } } }, responses: { 200: { description: 'Password reset successfully', content: { 'application/json': { schema: MessageResponseSchema } } }, - 400: { description: 'Invalid or expired reset token', content: { 'application/json': { schema: ErrorResponseSchema } } }, + 400: { description: 'Invalid or expired reset code', content: { 'application/json': { schema: ErrorResponseSchema } } }, }, }); diff --git a/src/constants/messages/auth.ts b/src/constants/messages/auth.ts index 6c45da5..ea99d98 100644 --- a/src/constants/messages/auth.ts +++ b/src/constants/messages/auth.ts @@ -6,45 +6,45 @@ * HTTP response message strings for the Auth domain. */ export const AUTH_MESSAGES = { - // signup - SIGNUP_SUCCESS: 'Account created successfully', - EMAIL_TAKEN: 'Email already registered', - - // login - INVALID_CREDENTIALS: 'Invalid email or password', - - // refresh - REFRESH_SUCCESS: 'Tokens refreshed successfully', - REFRESH_TOKEN_INVALID: 'Invalid or expired refresh token', - - // logout - LOGOUT_SUCCESS: 'Logged out successfully', - - // forgot-password - FORGOT_PASSWORD_SENT: 'If an account with that email exists, a reset link was sent', - - // reset-password - RESET_PASSWORD_SUCCESS: 'Password reset successfully', - RESET_TOKEN_INVALID: 'Invalid or expired reset token', - - // oauth - UNSUPPORTED_OAUTH_PROVIDER: 'Unsupported OAuth provider', - OAUTH_PROVIDER_NOT_CONFIGURED: 'OAuth provider is not configured', - OAUTH_CODE_MISSING: 'Authorization code missing from callback', - OAUTH_TOKEN_EXCHANGE_FAILED: 'Failed to exchange authorization code for tokens', - OAUTH_USER_INFO_FAILED: 'Failed to retrieve user info from provider', - OAUTH_EMAIL_MISSING: 'OAuth provider did not return an email address', - OAUTH_CANCELLED: 'OAuth flow was cancelled by the user', + // signup + SIGNUP_SUCCESS: "Account created successfully", + EMAIL_TAKEN: "Email already registered", + + // login + INVALID_CREDENTIALS: "Invalid email or password", + + // refresh + REFRESH_SUCCESS: "Tokens refreshed successfully", + REFRESH_TOKEN_INVALID: "Invalid or expired refresh token", + + // logout + LOGOUT_SUCCESS: "Logged out successfully", + + // forgot-password + FORGOT_PASSWORD_SENT: "If an account with that email exists, a reset link was sent", + + // reset-password + RESET_PASSWORD_SUCCESS: "Password reset successfully", + RESET_TOKEN_INVALID: "Invalid or expired reset token", + + // oauth + UNSUPPORTED_OAUTH_PROVIDER: "Unsupported OAuth provider", + OAUTH_PROVIDER_NOT_CONFIGURED: "OAuth provider is not configured", + OAUTH_CODE_MISSING: "Authorization code missing from callback", + OAUTH_TOKEN_EXCHANGE_FAILED: "Failed to exchange authorization code for tokens", + OAUTH_USER_INFO_FAILED: "Failed to retrieve user info from provider", + OAUTH_EMAIL_MISSING: "OAuth provider did not return an email address", + OAUTH_CANCELLED: "OAuth flow was cancelled by the user", } as const; /** * Zod schema validation error messages for the Auth domain. */ export const AUTH_VALIDATION = { - INVALID_EMAIL: 'Invalid email address', - PASSWORD_MIN_LENGTH: 'Password must be at least 8 characters', - PASSWORD_REQUIRED: 'Password is required', - NAME_REQUIRED: 'Name is required', - REFRESH_TOKEN_REQUIRED: 'Refresh token is required', - RESET_TOKEN_REQUIRED: 'Reset token is required', + INVALID_EMAIL: "Invalid email address", + PASSWORD_MIN_LENGTH: "Password must be at least 8 characters", + PASSWORD_REQUIRED: "Password is required", + NAME_REQUIRED: "Name is required", + REFRESH_TOKEN_REQUIRED: "Refresh token is required", + RESET_TOKEN_REQUIRED: "Reset token is required", } as const; diff --git a/src/index.ts b/src/index.ts index e89ceb2..804488e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,8 +9,6 @@ import redis from '@lib/redis'; import { env } from '@config/env'; async function startServer(): Promise { - console.info("Starting server with env:") - console.info(process.env) console.info('Connecting to PostgreSQL…'); try { await prisma.$connect(); diff --git a/src/lib/redis.ts b/src/lib/redis.ts index e234286..b94900a 100644 --- a/src/lib/redis.ts +++ b/src/lib/redis.ts @@ -2,41 +2,43 @@ // Proprietary and confidential. Unauthorized use is strictly prohibited. // See LICENSE file in the project root for full license information. -import Redis from 'ioredis'; -import { env } from '@config/env'; +import Redis from "ioredis"; +import { env } from "@config/env"; /** Redis client configured with retry strategy. */ const redis = new Redis(env.REDIS_URL, { - maxRetriesPerRequest: 3, - retryStrategy(times: number): number | null { - if (times > 5) return null; - return Math.min(times * 200, 2000); - }, + maxRetriesPerRequest: 3, + retryStrategy(times: number): number | null { + if (times > 5) return null; + return Math.min(times * 200, 2000); + }, }); -redis.on('error', (err) => { - console.error('Redis connection error:', err.message); +const workerRedis = new Redis(env.REDIS_URL, { + maxRetriesPerRequest: null, }); -export const refreshKey = (userId: string, token: string): string => - `refresh:${userId}:${token}`; +redis.on("error", (err) => { + console.error("Redis connection error:", err.message); +}); + +export const refreshKey = (userId: string, token: string): string => `refresh:${userId}:${token}`; -export const refreshPattern = (userId: string): string => - `refresh:${userId}:*`; +export const refreshPattern = (userId: string): string => `refresh:${userId}:*`; -export const resetKey = (hashedToken: string): string => - `reset:${hashedToken}`; +export const resetKey = (hashedToken: string): string => `reset:${hashedToken}`; /** SCAN-based key search using cursor iteration. */ export async function scanKeys(pattern: string): Promise { - const keys: string[] = []; - let cursor = '0'; - do { - const [nextCursor, batch] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100); - cursor = nextCursor; - keys.push(...batch); - } while (cursor !== '0'); - return keys; + const keys: string[] = []; + let cursor = "0"; + do { + const [nextCursor, batch] = await redis.scan(cursor, "MATCH", pattern, "COUNT", 100); + cursor = nextCursor; + keys.push(...batch); + } while (cursor !== "0"); + return keys; } export default redis; +export { workerRedis }; diff --git a/src/models/auth.ts b/src/models/auth.ts index 8451628..4e5f879 100644 --- a/src/models/auth.ts +++ b/src/models/auth.ts @@ -4,20 +4,20 @@ // Auth domain DTOs -import { z } from '@utils/zod'; -import type { ZodType } from 'zod'; -import { AUTH_VALIDATION } from '@constants/messages'; +import { AUTH_VALIDATION } from "@constants/messages"; import type { - SignupRequest as ContractSignupRequest, - LoginRequest as ContractLoginRequest, - ForgotPasswordRequest as ContractForgotPasswordRequest, - ResetPasswordRequest as ContractResetPasswordRequest, - RefreshRequest as ContractRefreshRequest, - LoginResponse as ContractLoginResponse, - RefreshResponse as ContractRefreshResponse, - TokenPair as ContractTokenPair, -} from '@coveritlabs/contracts'; -import type { Plain } from './common'; + ForgotPasswordRequest as ContractForgotPasswordRequest, + LoginRequest as ContractLoginRequest, + LoginResponse as ContractLoginResponse, + RefreshRequest as ContractRefreshRequest, + RefreshResponse as ContractRefreshResponse, + ResetPasswordRequest as ContractResetPasswordRequest, + SignupRequest as ContractSignupRequest, + TokenPair as ContractTokenPair, +} from "@coveritlabs/contracts"; +import { z } from "@utils/zod"; +import type { ZodType } from "zod"; +import type { Plain } from "./common"; export type SignupRequest = Plain; export type LoginRequest = Plain; @@ -29,25 +29,25 @@ export type TokenPair = Plain; export type RefreshRequest = Plain; export const SignupRequestSchema = z.object({ - email: z.email(AUTH_VALIDATION.INVALID_EMAIL), - password: z.requiredString(AUTH_VALIDATION.PASSWORD_REQUIRED).min(8, AUTH_VALIDATION.PASSWORD_MIN_LENGTH), - name: z.requiredString(AUTH_VALIDATION.NAME_REQUIRED), + email: z.email(AUTH_VALIDATION.INVALID_EMAIL), + password: z.requiredString(AUTH_VALIDATION.PASSWORD_REQUIRED).min(8, AUTH_VALIDATION.PASSWORD_MIN_LENGTH), + name: z.requiredString(AUTH_VALIDATION.NAME_REQUIRED), }) satisfies ZodType; export const LoginRequestSchema = z.object({ - email: z.email(AUTH_VALIDATION.INVALID_EMAIL), - password: z.requiredString(AUTH_VALIDATION.PASSWORD_REQUIRED), + email: z.email(AUTH_VALIDATION.INVALID_EMAIL), + password: z.requiredString(AUTH_VALIDATION.PASSWORD_REQUIRED), }) satisfies ZodType; export const ForgotPasswordRequestSchema = z.object({ - email: z.email(AUTH_VALIDATION.INVALID_EMAIL), + email: z.email(AUTH_VALIDATION.INVALID_EMAIL), }) satisfies ZodType; export const ResetPasswordRequestSchema = z.object({ - token: z.requiredString(AUTH_VALIDATION.RESET_TOKEN_REQUIRED), - newPassword: z.requiredString(AUTH_VALIDATION.PASSWORD_REQUIRED).min(8, AUTH_VALIDATION.PASSWORD_MIN_LENGTH), + token: z.requiredString(AUTH_VALIDATION.RESET_TOKEN_REQUIRED), + newPassword: z.requiredString(AUTH_VALIDATION.PASSWORD_REQUIRED).min(8, AUTH_VALIDATION.PASSWORD_MIN_LENGTH), }) satisfies ZodType; export const RefreshRequestSchema = z.object({ - refreshToken: z.requiredString(AUTH_VALIDATION.REFRESH_TOKEN_REQUIRED), + refreshToken: z.requiredString(AUTH_VALIDATION.REFRESH_TOKEN_REQUIRED), }) satisfies ZodType; diff --git a/src/queues/email.queue.ts b/src/queues/email.queue.ts new file mode 100644 index 0000000..2d7088a --- /dev/null +++ b/src/queues/email.queue.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +import { Queue } from "bullmq"; +import redis from "@lib/redis"; + +export const emailQueue = new Queue("email", { + connection: redis, +}); \ No newline at end of file diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts index 05e3c66..9908133 100644 --- a/src/services/auth.service.ts +++ b/src/services/auth.service.ts @@ -2,181 +2,200 @@ // Proprietary and confidential. Unauthorized use is strictly prohibited. // See LICENSE file in the project root for full license information. -import crypto from 'crypto'; -import argon2 from 'argon2'; - -import prisma from '@lib/prisma'; -import redis, { scanKeys, refreshKey, refreshPattern, resetKey } from '@lib/redis'; -import { env } from '@config/env'; -import { BadRequestError, ConflictError, UnauthorizedError } from '@utils/errors'; -import { generateAccessToken, generateRefreshToken, hashToken } from '@utils/token'; - +import argon2 from "argon2"; +import crypto from "crypto"; + +import { env } from "@config/env"; +import { buildRedirectUrl } from "@utils/redirect"; +import prisma from "@lib/prisma"; +import redis, { refreshKey, refreshPattern, resetKey, scanKeys } from "@lib/redis"; +import { emailQueue } from "@queues/email.queue"; +import { BadRequestError, ConflictError, UnauthorizedError } from "@utils/errors"; +import { generateAccessToken, generateRefreshToken, hashToken } from "@utils/token"; + +import { AUTH_MESSAGES } from "@constants/messages"; import type { - SignupRequest, - LoginRequest, - LoginResponse, - RefreshResponse, - ForgotPasswordRequest, - ResetPasswordRequest, -} from '@models/auth'; -import type { MessageResponse } from '@models/common'; -import { AUTH_MESSAGES } from '@constants/messages'; -import type { OAuthProvider } from 'types/auth'; - + ForgotPasswordRequest, + LoginRequest, + LoginResponse, + RefreshResponse, + ResetPasswordRequest, + SignupRequest, +} from "@models/auth"; +import type { MessageResponse } from "@models/common"; +import { logger } from "@services/logger.service"; +import type { OAuthProvider } from "types/auth"; export async function signup(input: SignupRequest): Promise { - const existing = await prisma.user.findUnique({ where: { email: input.email } }); - if (existing) { - throw new ConflictError(AUTH_MESSAGES.EMAIL_TAKEN); - } - - const hashedPassword = await argon2.hash(input.password); - - await prisma.user.create({ - data: { - email: input.email, - password: hashedPassword, - name: input.name, - }, - }); - - return { message: AUTH_MESSAGES.SIGNUP_SUCCESS }; + const existing = await prisma.user.findUnique({ where: { email: input.email } }); + if (existing) { + throw new ConflictError(AUTH_MESSAGES.EMAIL_TAKEN); + } + + const hashedPassword = await argon2.hash(input.password); + + await prisma.user.create({ + data: { + email: input.email, + password: hashedPassword, + name: input.name, + }, + }); + + return { message: AUTH_MESSAGES.SIGNUP_SUCCESS }; } export async function login(input: LoginRequest): Promise { - const user = await prisma.user.findUnique({ where: { email: input.email } }); - if (!user || !user.password) { - throw new UnauthorizedError(AUTH_MESSAGES.INVALID_CREDENTIALS); - } - - const valid = await argon2.verify(user.password, input.password); - if (!valid) { - throw new UnauthorizedError(AUTH_MESSAGES.INVALID_CREDENTIALS); - } - - const accessToken = generateAccessToken(user.id); - const rawRefreshToken = generateRefreshToken(); - const hashedRefresh = hashToken(rawRefreshToken); - - await redis.set( - refreshKey(user.id, hashedRefresh), - '1', - 'EX', - env.JWT_REFRESH_EXPIRY_SECONDS, - ); - - return { - tokens: { accessToken, refreshToken: rawRefreshToken }, - user: { id: user.id, email: user.email, name: user.name }, - }; + const user = await prisma.user.findUnique({ where: { email: input.email } }); + if (!user || !user.password) { + throw new UnauthorizedError(AUTH_MESSAGES.INVALID_CREDENTIALS); + } + + const valid = await argon2.verify(user.password, input.password); + if (!valid) { + throw new UnauthorizedError(AUTH_MESSAGES.INVALID_CREDENTIALS); + } + + const accessToken = generateAccessToken(user.id); + const rawRefreshToken = generateRefreshToken(); + const hashedRefresh = hashToken(rawRefreshToken); + + await redis.set(refreshKey(user.id, hashedRefresh), "1", "EX", env.JWT_REFRESH_EXPIRY_SECONDS); + + return { + tokens: { accessToken, refreshToken: rawRefreshToken }, + user: { id: user.id, email: user.email, name: user.name }, + }; } export async function refresh(oldRawToken: string): Promise { - const oldHash = hashToken(oldRawToken); - const matchedKeys = await scanKeys(`refresh:*:${oldHash}`); - if (matchedKeys.length === 0) { - throw new UnauthorizedError(AUTH_MESSAGES.REFRESH_TOKEN_INVALID); - } + const oldHash = hashToken(oldRawToken); + const matchedKeys = await scanKeys(`refresh:*:${oldHash}`); + if (matchedKeys.length === 0) { + throw new UnauthorizedError(AUTH_MESSAGES.REFRESH_TOKEN_INVALID); + } - const key = matchedKeys[0]; - const userId = key.split(':')[1]; + const key = matchedKeys[0]; + const userId = key.split(":")[1]; - await redis.del(key); + await redis.del(key); - const accessToken = generateAccessToken(userId); - const newRawRefresh = generateRefreshToken(); - const newHash = hashToken(newRawRefresh); + const accessToken = generateAccessToken(userId); + const newRawRefresh = generateRefreshToken(); + const newHash = hashToken(newRawRefresh); - await redis.set( - refreshKey(userId, newHash), - '1', - 'EX', - env.JWT_REFRESH_EXPIRY_SECONDS, - ); + await redis.set(refreshKey(userId, newHash), "1", "EX", env.JWT_REFRESH_EXPIRY_SECONDS); - return { - message: AUTH_MESSAGES.REFRESH_SUCCESS, - tokens: { accessToken, refreshToken: newRawRefresh }, - }; + return { + message: AUTH_MESSAGES.REFRESH_SUCCESS, + tokens: { accessToken, refreshToken: newRawRefresh }, + }; } export async function logout(rawRefreshToken: string): Promise { - const hash = hashToken(rawRefreshToken); - const matchedKeys = await scanKeys(`refresh:*:${hash}`); - if (matchedKeys.length > 0) { - await redis.del(matchedKeys[0]); - } + const hash = hashToken(rawRefreshToken); + const matchedKeys = await scanKeys(`refresh:*:${hash}`); + if (matchedKeys.length > 0) { + await redis.del(matchedKeys[0]); + } - return { message: AUTH_MESSAGES.LOGOUT_SUCCESS }; + return { message: AUTH_MESSAGES.LOGOUT_SUCCESS }; } export async function forgotPassword(input: ForgotPasswordRequest): Promise { - const user = await prisma.user.findUnique({ where: { email: input.email } }); - if (!user) return; + const user = await prisma.user.findUnique({ where: { email: input.email } }); + if (!user) return; + + const rawToken = crypto.randomBytes(32).toString("hex"); + const hashedToken = hashToken(rawToken); - const rawToken = crypto.randomBytes(32).toString('base64url'); - const hashed = hashToken(rawToken); + await redis.set(resetKey(hashedToken), user.id, "EX", env.RESET_TOKEN_TTL_SECONDS); - await redis.set(resetKey(hashed), user.id, 'EX', env.RESET_TOKEN_TTL_SECONDS); + const resetUrl = buildRedirectUrl(env.FRONTEND_URL, "/reset-password", { token: rawToken }); - // TODO: replace with email job enqueue - console.info(`[job:email] Enqueue password-reset email for userId=${user.id} token=${rawToken}`); + logger.info(`[job:email] Enqueue password-reset email for userId=${user.id}`); + await emailQueue.add("send-reset-email", { + userId: user.id, + email: user.email, + name: user.name, + resetUrl, + }); } export async function resetPassword(input: ResetPasswordRequest): Promise { - const hashed = hashToken(input.token); - const userId = await redis.get(resetKey(hashed)); + const hashedToken = hashToken(input.token); + const userId = await redis.get(resetKey(hashedToken)); - if (!userId) { - throw new BadRequestError(AUTH_MESSAGES.RESET_TOKEN_INVALID); - } + if (!userId) { + throw new BadRequestError(AUTH_MESSAGES.RESET_TOKEN_INVALID); + } - const hashedPassword = await argon2.hash(input.newPassword); + const hashedPassword = await argon2.hash(input.newPassword); - await prisma.user.update({ - where: { id: userId }, - data: { password: hashedPassword }, - }); + await prisma.user.update({ + where: { id: userId }, + data: { password: hashedPassword }, + }); - await redis.del(resetKey(hashed)); + await redis.del(resetKey(hashedToken)); - const refreshKeys = await scanKeys(refreshPattern(userId)); - if (refreshKeys.length > 0) { - await redis.del(...refreshKeys); - } + const refreshKeys = await scanKeys(refreshPattern(userId)); + if (refreshKeys.length > 0) { + await redis.del(...refreshKeys); + } - return { message: AUTH_MESSAGES.RESET_PASSWORD_SUCCESS }; + return { message: AUTH_MESSAGES.RESET_PASSWORD_SUCCESS }; } export async function oauthLogin( - provider: OAuthProvider, - profile: { email: string; name: string }, + provider: OAuthProvider, + profile: { email: string; name: string; providerAccountId: string }, ): Promise { - let user = await prisma.user.findUnique({ where: { email: profile.email } }); - - if (!user) { - user = await prisma.user.create({ - data: { - email: profile.email, - name: profile.name, - provider, - }, - }); + let user = await prisma.user.findUnique({ where: { email: profile.email } }); + + if (user) { + const existingAccount = await prisma.account.findFirst({ + where: { userId: user.id, provider }, + }); + + if (!existingAccount) { + await prisma.account.create({ + data: { + userId: user.id, + provider, + providerAccountId: profile.providerAccountId, + }, + }); } + } else { + user = await prisma.$transaction(async (tx) => { + const newUser = await tx.user.create({ + data: { + email: profile.email, + name: profile.name, + }, + }); + + await tx.account.create({ + data: { + userId: newUser.id, + provider, + providerAccountId: profile.providerAccountId, + }, + }); + + return newUser; + }); + } + + const accessToken = generateAccessToken(user.id); + const rawRefreshToken = generateRefreshToken(); + const hashedRefresh = hashToken(rawRefreshToken); + + await redis.set(refreshKey(user.id, hashedRefresh), "1", "EX", env.JWT_REFRESH_EXPIRY_SECONDS); - const accessToken = generateAccessToken(user.id); - const rawRefreshToken = generateRefreshToken(); - const hashedRefresh = hashToken(rawRefreshToken); - - await redis.set( - refreshKey(user.id, hashedRefresh), - '1', - 'EX', - env.JWT_REFRESH_EXPIRY_SECONDS, - ); - - return { - tokens: { accessToken, refreshToken: rawRefreshToken }, - user: { id: user.id, email: user.email, name: user.name }, - }; + return { + tokens: { accessToken, refreshToken: rawRefreshToken }, + user: { id: user.id, email: user.email, name: user.name }, + }; } diff --git a/src/services/email.service.ts b/src/services/email.service.ts new file mode 100644 index 0000000..ee15c59 --- /dev/null +++ b/src/services/email.service.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +import { env } from "@config/env"; +import { logger } from "@services/logger.service"; +import { Resend } from "resend"; + +const resend = new Resend(env.RESEND_API_KEY); + +export async function sendResetEmail(email: string, resetUrl: string, name: string): Promise { + const from = env.RESET_PASSWORD_EMAIL; + const templateId = env.RESET_PASSWORD_TEMPLATE_ID; + + const { data, error } = await resend.emails.send({ + from, + to: [email], + subject: "Reset your Coverit password", + template: { + id: templateId, + variables: { + NAME: name, + RESET_URL: resetUrl, + EXPIRE_TIME: Math.ceil(env.RESET_TOKEN_TTL_SECONDS / 60), + }, + }, + }); + + if (error) { + logger.error(error, "Error sending email:"); + return; + } + + logger.info("Reset password email sent successfully!"); + logger.info( + { + email, + messageId: data?.id, + }, + "Reset password email sent", + ); +} diff --git a/src/services/oauth.service.ts b/src/services/oauth.service.ts index d10dc37..d34af3f 100644 --- a/src/services/oauth.service.ts +++ b/src/services/oauth.service.ts @@ -116,7 +116,7 @@ async function fetchGoogleProfile(accessToken: string): Promise { @@ -146,5 +146,5 @@ async function fetchGitHubProfile(accessToken: string): Promise +): string { + if (!baseUrl) return pathname || ''; + + const normalizedBase = baseUrl.replace(/\/+$/g, ''); + const normalizedPath = pathname ? '/' + pathname.replace(/^\/+/, '') : ''; + + const url = `${normalizedBase}${normalizedPath}`; + + if (!params) return url; + + const search = params instanceof URLSearchParams + ? params.toString() + : new URLSearchParams(params).toString(); + + return search ? `${url}?${search}` : url; +} + +export default buildRedirectUrl; diff --git a/src/workers/email.worker.ts b/src/workers/email.worker.ts new file mode 100644 index 0000000..a44c885 --- /dev/null +++ b/src/workers/email.worker.ts @@ -0,0 +1,21 @@ +// Copyright (c) 2026 CoverIt Labs. All Rights Reserved. +// Proprietary and confidential. Unauthorized use is strictly prohibited. +// See LICENSE file in the project root for full license information. + +import { workerRedis } from '@lib/redis'; +import { sendResetEmail } from "@services/email.service"; +import { logger } from "@services/logger.service"; +import { Worker } from "bullmq"; + +new Worker( + "email", + async (job) => { + if (job.name === "send-reset-email") { + const { email, resetUrl, name } = job.data; + await sendResetEmail(email, resetUrl, name); + } + }, + { connection: workerRedis }, +); + +logger.info("[Worker] Email worker started and listening for jobs..."); diff --git a/tsconfig.json b/tsconfig.json index ae4ed54..9d45c68 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,7 +16,9 @@ "@utils/*": ["utils/*"], "@generated/*": ["generated/*"], "@models/*": ["models/*"], - "@constants/*": ["constants/*"] + "@constants/*": ["constants/*"], + "@queues/*": ["queues/*"], + "@workers/*": ["workers/*"], }, "strict": true, "esModuleInterop": true,