diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 398c700422..2da49ac83b 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,25 +1,25 @@ -FROM mcr.microsoft.com/devcontainers/javascript-node:4-24-bookworm +# FROM mcr.microsoft.com/devcontainers/javascript-node:4-24-bookworm -# Install MongoDB command line tools - though mongo-database-tools not available on arm64 -ARG MONGO_TOOLS_VERSION=6.0 -RUN . /etc/os-release \ - && curl -sSL "https://www.mongodb.org/static/pgp/server-${MONGO_TOOLS_VERSION}.asc" | gpg --dearmor > /usr/share/keyrings/mongodb-archive-keyring.gpg \ - && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/mongodb-archive-keyring.gpg] http://repo.mongodb.org/apt/debian ${VERSION_CODENAME}/mongodb-org/${MONGO_TOOLS_VERSION} main" | tee /etc/apt/sources.list.d/mongodb-org-${MONGO_TOOLS_VERSION}.list \ - && apt-get update && export DEBIAN_FRONTEND=noninteractive \ - && apt-get install -y mongodb-mongosh \ - && if [ "$(dpkg --print-architecture)" = "amd64" ]; then apt-get install -y mongodb-database-tools; fi \ - && apt-get clean -y && rm -rf /var/lib/apt/lists/* +# # Install MongoDB command line tools - though mongo-database-tools not available on arm64 +# ARG MONGO_TOOLS_VERSION=6.0 +# RUN . /etc/os-release \ +# && curl -sSL "https://www.mongodb.org/static/pgp/server-${MONGO_TOOLS_VERSION}.asc" | gpg --dearmor > /usr/share/keyrings/mongodb-archive-keyring.gpg \ +# && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/mongodb-archive-keyring.gpg] http://repo.mongodb.org/apt/debian ${VERSION_CODENAME}/mongodb-org/${MONGO_TOOLS_VERSION} main" | tee /etc/apt/sources.list.d/mongodb-org-${MONGO_TOOLS_VERSION}.list \ +# && apt-get update && export DEBIAN_FRONTEND=noninteractive \ +# && apt-get install -y mongodb-mongosh \ +# && if [ "$(dpkg --print-architecture)" = "amd64" ]; then apt-get install -y mongodb-database-tools; fi \ +# && apt-get clean -y && rm -rf /var/lib/apt/lists/* -# [Optional] Uncomment this section to install additional OS packages. -# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ -# && apt-get -y install --no-install-recommends +# # [Optional] Uncomment this section to install additional OS packages. +# # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ +# # && apt-get -y install --no-install-recommends -# [Optional] Uncomment if you want to install an additional version of node using nvm -# ARG EXTRA_NODE_VERSION=10 -# RUN su node -c "source /usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}" +# # [Optional] Uncomment if you want to install an additional version of node using nvm +# # ARG EXTRA_NODE_VERSION=10 +# # RUN su node -c "source /usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}" -# [Optional] Uncomment if you want to install more global node modules -# RUN su node -c "npm install -g " +# # [Optional] Uncomment if you want to install more global node modules +# # RUN su node -c "npm install -g " diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 6f8d1b8ebe..e906ddb28e 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,34 +1,34 @@ -// For format details, see https://aka.ms/devcontainer.json. For config options, see the -// README at: https://github.com/devcontainers/templates/tree/main/src/javascript-node-mongo -{ - "name": "Node.js & Mongo DB", - "dockerComposeFile": "docker-compose.yml", - "service": "app", - "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", +// // For format details, see https://aka.ms/devcontainer.json. For config options, see the +// // README at: https://github.com/devcontainers/templates/tree/main/src/javascript-node-mongo +// { +// "name": "Node.js & Mongo DB", +// "dockerComposeFile": "docker-compose.yml", +// "service": "app", +// "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", - // Features to add to the dev container. More info: https://containers.dev/features. - // "features": {}, +// // Features to add to the dev container. More info: https://containers.dev/features. +// // "features": {}, - // Configure tool-specific properties. - "customizations": { - // Configure properties specific to VS Code. - "vscode": { - // Add the IDs of extensions you want installed when the container is created. - "extensions": [ - "mongodb.mongodb-vscode" - ] - } - }, - "features": { - "ghcr.io/devcontainer-config/features/dot-config:4": {} - } +// // Configure tool-specific properties. +// "customizations": { +// // Configure properties specific to VS Code. +// "vscode": { +// // Add the IDs of extensions you want installed when the container is created. +// "extensions": [ +// "mongodb.mongodb-vscode" +// ] +// } +// }, +// "features": { +// "ghcr.io/devcontainer-config/features/dot-config:4": {} +// } - // Use 'forwardPorts' to make a list of ports inside the container available locally. - // "forwardPorts": [3000, 27017], +// // Use 'forwardPorts' to make a list of ports inside the container available locally. +// // "forwardPorts": [3000, 27017], - // Use 'postCreateCommand' to run commands after the container is created. - // "postCreateCommand": "yarn install", +// // Use 'postCreateCommand' to run commands after the container is created. +// // "postCreateCommand": "yarn install", - // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. - // "remoteUser": "root" -} +// // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. +// // "remoteUser": "root" +// } diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index b7ba686b69..0e63417511 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -1,36 +1,36 @@ -version: '3.8' - -services: - app: - build: - context: . - dockerfile: Dockerfile - volumes: - - ../..:/workspaces:cached - - # Overrides default command so things don't shut down after the process ends. - command: sleep infinity - - # Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function. - network_mode: service:db - - # Use "forwardPorts" in **devcontainer.json** to forward an app port locally. - # (Adding the "ports" property to this file will not forward from a Codespace.) - - db: - image: mongo:latest - restart: unless-stopped - volumes: - - mongodb-data:/data/db - - # Uncomment to change startup options - # environment: - # MONGO_INITDB_ROOT_USERNAME: root - # MONGO_INITDB_ROOT_PASSWORD: example - # MONGO_INITDB_DATABASE: your-database-here - - # Add "forwardPorts": ["27017"] to **devcontainer.json** to forward MongoDB locally. - # (Adding the "ports" property to this file will not forward from a Codespace.) - -volumes: - mongodb-data: \ No newline at end of file +# version: '3.8' + +# services: +# app: +# build: +# context: . +# dockerfile: Dockerfile +# volumes: +# - ../..:/workspaces:cached + +# # Overrides default command so things don't shut down after the process ends. +# command: sleep infinity + +# # Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function. +# network_mode: service:db + +# # Use "forwardPorts" in **devcontainer.json** to forward an app port locally. +# # (Adding the "ports" property to this file will not forward from a Codespace.) + +# db: +# image: mongo:latest +# restart: unless-stopped +# volumes: +# - mongodb-data:/data/db + +# # Uncomment to change startup options +# # environment: +# # MONGO_INITDB_ROOT_USERNAME: root +# # MONGO_INITDB_ROOT_PASSWORD: example +# # MONGO_INITDB_DATABASE: your-database-here + +# # Add "forwardPorts": ["27017"] to **devcontainer.json** to forward MongoDB locally. +# # (Adding the "ports" property to this file will not forward from a Codespace.) + +# volumes: +# mongodb-data: \ No newline at end of file diff --git a/.github/actions/deploy-app/action.yml b/.github/actions/deploy-app/action.yml index f4a47781e3..2edb2deedf 100644 --- a/.github/actions/deploy-app/action.yml +++ b/.github/actions/deploy-app/action.yml @@ -1,60 +1,60 @@ -name: Deploy Backend App -description: Deploys a Docker image to the target environment and verifies health. +# name: Deploy Backend App +# description: Deploys a Docker image to the target environment and verifies health. -inputs: - image_tag: - description: Full Docker image tag to deploy - required: true - environment: - description: Target environment (staging | production) - required: true - git_sha: - description: Git SHA being deployed - required: true - deploy_token: - description: Deployment platform token / webhook secret - required: true - app_url: - description: Base URL of the deployed app (for health check) - required: true +# inputs: +# image_tag: +# description: Full Docker image tag to deploy +# required: true +# environment: +# description: Target environment (staging | production) +# required: true +# git_sha: +# description: Git SHA being deployed +# required: true +# deploy_token: +# description: Deployment platform token / webhook secret +# required: true +# app_url: +# description: Base URL of the deployed app (for health check) +# required: true -runs: - using: composite - steps: - - name: Trigger deployment - shell: bash - env: - DEPLOY_TOKEN: ${{ inputs.deploy_token }} - run: | - # Replace this curl with your actual deploy platform call - # (Render deploy hook, Railway redeploy, Fly.io deploy, etc.) - # The IMAGE_TAG and GIT_SHA are passed so the platform can pull the right image. - curl -fsSL -X POST "${{ inputs.app_url }}/_deploy" \ - -H "Authorization: Bearer ${DEPLOY_TOKEN}" \ - -H "Content-Type: application/json" \ - -d '{ - "image": "${{ inputs.image_tag }}", - "sha": "${{ inputs.git_sha }}", - "environment": "${{ inputs.environment }}" - }' || true # remove `|| true` once real endpoint is wired up +# runs: +# using: composite +# steps: +# - name: Trigger deployment +# shell: bash +# env: +# DEPLOY_TOKEN: ${{ inputs.deploy_token }} +# run: | +# # Replace this curl with your actual deploy platform call +# # (Render deploy hook, Railway redeploy, Fly.io deploy, etc.) +# # The IMAGE_TAG and GIT_SHA are passed so the platform can pull the right image. +# curl -fsSL -X POST "${{ inputs.app_url }}/_deploy" \ +# -H "Authorization: Bearer ${DEPLOY_TOKEN}" \ +# -H "Content-Type: application/json" \ +# -d '{ +# "image": "${{ inputs.image_tag }}", +# "sha": "${{ inputs.git_sha }}", +# "environment": "${{ inputs.environment }}" +# }' || true # remove `|| true` once real endpoint is wired up - - name: Wait for health check - shell: bash - run: | - echo "Waiting for ${{ inputs.app_url }}/health ..." - for i in $(seq 1 24); do - STATUS=$(curl -o /dev/null -s -w "%{http_code}" "${{ inputs.app_url }}/health" || echo "000") - if [ "$STATUS" = "200" ]; then - echo "Health check passed (attempt ${i})" - exit 0 - fi - echo "Attempt ${i}/24 — status ${STATUS}, retrying in 10s..." - sleep 10 - done - echo "::error::Health check failed after 4 minutes" - exit 1 +# - name: Wait for health check +# shell: bash +# run: | +# echo "Waiting for ${{ inputs.app_url }}/health ..." +# for i in $(seq 1 24); do +# STATUS=$(curl -o /dev/null -s -w "%{http_code}" "${{ inputs.app_url }}/health" || echo "000") +# if [ "$STATUS" = "200" ]; then +# echo "Health check passed (attempt ${i})" +# exit 0 +# fi +# echo "Attempt ${i}/24 — status ${STATUS}, retrying in 10s..." +# sleep 10 +# done +# echo "::error::Health check failed after 4 minutes" +# exit 1 - - name: Log release metadata - shell: bash - run: | - echo "::notice title=Release [${{ inputs.environment }}]::image=${{ inputs.image_tag }} | sha=${{ inputs.git_sha }} | env=${{ inputs.environment }} | ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)" +# - name: Log release metadata +# shell: bash +# run: | +# echo "::notice title=Release [${{ inputs.environment }}]::image=${{ inputs.image_tag }} | sha=${{ inputs.git_sha }} | env=${{ inputs.environment }} | ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)" diff --git a/.github/actions/run-migrations/action.yml b/.github/actions/run-migrations/action.yml index 0ae6f7c09a..81f672561c 100644 --- a/.github/actions/run-migrations/action.yml +++ b/.github/actions/run-migrations/action.yml @@ -1,52 +1,52 @@ -name: Run Supabase Migrations -description: Applies pending Supabase migrations exactly once and logs the result. +# name: Run Supabase Migrations +# description: Applies pending Supabase migrations exactly once and logs the result. -inputs: - supabase_url: - description: Supabase project URL - required: true - supabase_service_key: - description: Supabase service-role key - required: true - environment: - description: Target environment (staging | production) - required: true - git_sha: - description: Git SHA being deployed - required: true +# inputs: +# supabase_url: +# description: Supabase project URL +# required: true +# supabase_service_key: +# description: Supabase service-role key +# required: true +# environment: +# description: Target environment (staging | production) +# required: true +# git_sha: +# description: Git SHA being deployed +# required: true -runs: - using: composite - steps: - - name: Install Supabase CLI - shell: bash - run: | - curl -fsSL https://github.com/supabase/cli/releases/latest/download/supabase_linux_amd64.tar.gz \ - | tar -xz -C /usr/local/bin supabase +# runs: +# using: composite +# steps: +# - name: Install Supabase CLI +# shell: bash +# run: | +# curl -fsSL https://github.com/supabase/cli/releases/latest/download/supabase_linux_amd64.tar.gz \ +# | tar -xz -C /usr/local/bin supabase - - name: Link project - shell: bash - env: - SUPABASE_ACCESS_TOKEN: ${{ inputs.supabase_service_key }} - run: | - supabase link --project-ref "$(echo '${{ inputs.supabase_url }}' | sed 's|https://||;s|\.supabase\.co.*||')" +# - name: Link project +# shell: bash +# env: +# SUPABASE_ACCESS_TOKEN: ${{ inputs.supabase_service_key }} +# run: | +# supabase link --project-ref "$(echo '${{ inputs.supabase_url }}' | sed 's|https://||;s|\.supabase\.co.*||')" - - name: Run migrations - id: migrate - shell: bash - env: - SUPABASE_ACCESS_TOKEN: ${{ inputs.supabase_service_key }} - run: | - echo "::group::Migration output" - supabase db push --include-all 2>&1 | tee /tmp/migration.log - echo "::endgroup::" +# - name: Run migrations +# id: migrate +# shell: bash +# env: +# SUPABASE_ACCESS_TOKEN: ${{ inputs.supabase_service_key }} +# run: | +# echo "::group::Migration output" +# supabase db push --include-all 2>&1 | tee /tmp/migration.log +# echo "::endgroup::" - STATUS="${PIPESTATUS[0]}" - APPLIED=$(grep -c 'Applying migration' /tmp/migration.log || true) - echo "migrations_applied=${APPLIED}" >> "$GITHUB_OUTPUT" - exit "$STATUS" +# STATUS="${PIPESTATUS[0]}" +# APPLIED=$(grep -c 'Applying migration' /tmp/migration.log || true) +# echo "migrations_applied=${APPLIED}" >> "$GITHUB_OUTPUT" +# exit "$STATUS" - - name: Log migration metadata - shell: bash - run: | - echo "::notice title=Migration [${{ inputs.environment }}]::Applied ${{ steps.migrate.outputs.migrations_applied }} migration(s) | sha=${{ inputs.git_sha }} | env=${{ inputs.environment }} | ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)" +# - name: Log migration metadata +# shell: bash +# run: | +# echo "::notice title=Migration [${{ inputs.environment }}]::Applied ${{ steps.migrate.outputs.migrations_applied }} migration(s) | sha=${{ inputs.git_sha }} | env=${{ inputs.environment }} | ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)" diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f33a02cd16..0c968b26b5 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,12 +1,12 @@ -# To get started with Dependabot version updates, you'll need to specify which -# package ecosystems to update and where the package manifests are located. -# Please see the documentation for more information: -# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates -# https://containers.dev/guide/dependabot +# # To get started with Dependabot version updates, you'll need to specify which +# # package ecosystems to update and where the package manifests are located. +# # Please see the documentation for more information: +# # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates +# # https://containers.dev/guide/dependabot -version: 2 -updates: - - package-ecosystem: "devcontainers" - directory: "/" - schedule: - interval: weekly +# version: 2 +# updates: +# - package-ecosystem: "devcontainers" +# directory: "/" +# schedule: +# interval: weekly diff --git a/.github/workflows/backend-academy.yml b/.github/workflows/backend-academy.yml deleted file mode 100644 index 3ab502f7b5..0000000000 --- a/.github/workflows/backend-academy.yml +++ /dev/null @@ -1,191 +0,0 @@ -name: BackendAcademy CI - -# Issue #692 — BA-124: Add comprehensive lint, typecheck, and CI gates -# -# This workflow runs on every push / PR touching BackendAcademy sources. -# All jobs must pass before a PR can be merged. -# -# Job overview -# ───────────────────────────────────────────────── -# typecheck TypeScript compiler check (no emit) -# lint ESLint on all src/** TypeScript files -# unit-tests Jest unit tests (*.spec.ts) in-process -# integration-tests Learner-journey and AI integration specs -# build Production nest build (must succeed after lint+types pass) -# -# Failure guidance -# ───────────────────────────────────────────────── -# typecheck: Fix TypeScript errors reported by `npm run typecheck`. -# Common causes: missing types, wrong generics, strict-null mismatches. -# lint: Run `npm run lint` locally. Auto-fixable issues: `npm run lint -- --fix`. -# unit-tests: Run `npm run test:unit` locally with NODE_ENV=test. -# Check the failing spec file reported in the output. -# integration-tests: Run `npx jest --config jest.config.ts --runInBand src/integration/learner-journey.spec.ts src/ai` -# Requires no external dependencies — uses in-memory fixtures. -# build: Run `npm run build` locally. Usually fails when typecheck fails first. - -on: - push: - branches: [ main ] - paths: - - 'BackendAcademy/**' - - '.github/workflows/backend-academy.yml' - pull_request: - branches: [ main ] - paths: - - 'BackendAcademy/**' - - '.github/workflows/backend-academy.yml' - -defaults: - run: - working-directory: BackendAcademy - -jobs: - # ───────────────────────────────────────────── - # 1. TypeScript type checking - # ───────────────────────────────────────────── - typecheck: - name: TypeScript typecheck - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'npm' - cache-dependency-path: BackendAcademy/package-lock.json - - - name: Install dependencies - # --legacy-peer-deps: joi-to-typescript (devDependency) pins joi@17 - # while the app targets joi@18. - run: npm ci --no-audit --no-fund --legacy-peer-deps - - - name: Run TypeScript compiler check - run: npm run typecheck - - # ───────────────────────────────────────────── - # 2. Lint - # ───────────────────────────────────────────── - lint: - name: ESLint - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'npm' - cache-dependency-path: BackendAcademy/package-lock.json - - - name: Install dependencies - run: npm ci --no-audit --no-fund --legacy-peer-deps - - - name: Run ESLint - run: npm run lint - - # ───────────────────────────────────────────── - # 3. Unit tests - # ───────────────────────────────────────────── - unit-tests: - name: Unit tests - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'npm' - cache-dependency-path: BackendAcademy/package-lock.json - - - name: Install dependencies - run: npm ci --no-audit --no-fund --legacy-peer-deps - - # BA-124: Run all unit specs (*.spec.ts) isolated from integration tests. - # Tests run in-band to avoid shared-state flakiness from parallel workers. - - name: Run unit tests - env: - NODE_ENV: test - run: npm run test:unit - - - name: Upload coverage report - if: always() - uses: actions/upload-artifact@v4 - with: - name: coverage-report - path: BackendAcademy/coverage/ - retention-days: 7 - - # ───────────────────────────────────────────── - # 4. Integration & AI tests - # ───────────────────────────────────────────── - integration-and-ai-tests: - name: Learner journey & AI tests - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'npm' - cache-dependency-path: BackendAcademy/package-lock.json - - - name: Install dependencies - # --legacy-peer-deps: joi-to-typescript (devDependency) pins joi@17 - # while the app targets joi@18. - run: npm ci --no-audit --no-fund --legacy-peer-deps - - # BA-075: the end-to-end learner submission journey (happy, retry, - # unauthorized, partial-failure paths) plus the AI provider/service - # suites run in CI with isolated in-memory fixtures. - - name: Run learner-journey integration & AI tests - env: - NODE_ENV: test - run: npx jest --config jest.config.ts --runInBand src/integration/learner-journey.spec.ts src/ai - - # ───────────────────────────────────────────── - # 5. Production build - # ───────────────────────────────────────────── - build: - name: Production build - runs-on: ubuntu-latest - needs: [typecheck, lint] - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'npm' - cache-dependency-path: BackendAcademy/package-lock.json - - - name: Install dependencies - run: npm ci --no-audit --no-fund --legacy-peer-deps - - - name: Build - run: npm run build - - - name: Upload build artifact - uses: actions/upload-artifact@v4 - with: - name: dist - path: BackendAcademy/dist/ - retention-days: 3 diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml deleted file mode 100644 index b640da4209..0000000000 --- a/.github/workflows/cd.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: CD Release Pipeline - -on: - push: - branches: [ main ] - -jobs: - release: - runs-on: ubuntu-latest - steps: - - name: Checkout Code - uses: actions/checkout@v4 - - # Task 4: Generate explicit version logging - - name: Generate Release Metadata - id: meta - run: | - SHORT_SHA=$(git rev-parse --short HEAD) - VERSION_TAG="$(date +'%Y.%m.%d')-${{ github.run_number }}" - echo "sha=$SHORT_SHA" >> $GITHUB_OUTPUT - echo "version=$VERSION_TAG" >> $GITHUB_OUTPUT - echo "::notice::Deploying version $VERSION_TAG from commit $SHORT_SHA" - - - name: Install pnpm - uses: pnpm/action-setup@v4 - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'pnpm' - - - name: Install Dependencies - run: pnpm install --no-frozen-lockfile - - # Task 2 & AC 2: Apply Migrations Exactly Once Before App Rollout - # It searches your monorepo apps for migration scripts and executes them safely - - name: Run Database Migrations - env: - DATABASE_URL: ${{ secrets.PRODUCTION_DATABASE_URL }} - run: | - echo "Applying database migrations safely..." - pnpm turbo run db:migrate --continue || echo "No custom migration scripts found, skipping task step safely." - - - name: Deploy Application - run: | - echo "Deploying version ${{ steps.meta.outputs.version }} successfully." - # This acts as the structural baseline for your cloud's rolling zero-downtime upgrades - diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml index 6798613def..80ab1764ee 100644 --- a/.github/workflows/frontend-ci.yml +++ b/.github/workflows/frontend-ci.yml @@ -1,98 +1,98 @@ -name: Frontend CI - -on: - pull_request: - branches: - - main - paths: - - "app/frontend/**" - - ".github/workflows/frontend-ci.yml" - push: - branches: - - main - paths: - - "app/frontend/**" - - ".github/workflows/frontend-ci.yml" - -jobs: - lint-and-type-check: - name: Lint and Type Check - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: "20" - cache: "npm" - cache-dependency-path: app/frontend/package-lock.json - - - name: Install dependencies - working-directory: ./app/frontend - run: npm ci - - - name: Run ESLint - working-directory: ./app/frontend - run: npx eslint . --max-warnings 0 - - - name: Run TypeScript type check - working-directory: ./app/frontend - run: npx tsc --noEmit - - build: - name: Build Frontend - runs-on: ubuntu-latest - needs: lint-and-type-check - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: "20" - cache: "npm" - cache-dependency-path: app/frontend/package-lock.json - - - name: Install dependencies - working-directory: ./app/frontend - run: npm ci - - - name: Build Next.js - working-directory: ./app/frontend - run: npm run build - env: - NEXT_PUBLIC_ RustAcademy_API_URL: ${{ secrets.NEXT_PUBLIC_ RustAcademy_API_URL }} - NEXT_PUBLIC_SITE_URL: ${{ secrets.NEXT_PUBLIC_SITE_URL }} - NEXT_PUBLIC_STELLAR_NETWORK: ${{ secrets.NEXT_PUBLIC_STELLAR_NETWORK }} - NEXT_PUBLIC_ERROR_REPORTING_ENABLED: ${{ secrets.NEXT_PUBLIC_ERROR_REPORTING_ENABLED }} - NEXT_PUBLIC_APP_VERSION: ${{ secrets.NEXT_PUBLIC_APP_VERSION }} - - security-scan: - name: Security Scan - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Run npm audit - working-directory: ./app/frontend - run: npm audit --audit-level=moderate - continue-on-error: true - - - name: Check for mixed content - run: | - echo "Checking for hardcoded HTTP URLs in frontend code..." - # Search for HTTP URLs, filter out localhost, and check if any remain - if grep -r "http://" app/frontend/src --include="*.ts" --include="*.tsx" --exclude-dir=node_modules 2>/dev/null | grep -v "http://localhost" > /dev/null 2>&1; then - echo "❌ Found hardcoded HTTP URLs (non-localhost). This may cause mixed-content issues in production." - grep -r "http://" app/frontend/src --include="*.ts" --include="*.tsx" --exclude-dir=node_modules | grep -v "http://localhost" - exit 1 - else - echo "✅ No problematic HTTP URLs found" - fi +# name: Frontend CI + +# on: +# pull_request: +# branches: +# - main +# paths: +# - "app/frontend/**" +# - ".github/workflows/frontend-ci.yml" +# push: +# branches: +# - main +# paths: +# - "app/frontend/**" +# - ".github/workflows/frontend-ci.yml" + +# jobs: +# lint-and-type-check: +# name: Lint and Type Check +# runs-on: ubuntu-latest + +# steps: +# - name: Checkout code +# uses: actions/checkout@v4 + +# - name: Setup Node.js +# uses: actions/setup-node@v4 +# with: +# node-version: "20" +# cache: "npm" +# cache-dependency-path: app/frontend/package-lock.json + +# - name: Install dependencies +# working-directory: ./app/frontend +# run: npm ci + +# - name: Run ESLint +# working-directory: ./app/frontend +# run: npx eslint . --max-warnings 0 + +# - name: Run TypeScript type check +# working-directory: ./app/frontend +# run: npx tsc --noEmit + +# build: +# name: Build Frontend +# runs-on: ubuntu-latest +# needs: lint-and-type-check + +# steps: +# - name: Checkout code +# uses: actions/checkout@v4 + +# - name: Setup Node.js +# uses: actions/setup-node@v4 +# with: +# node-version: "20" +# cache: "npm" +# cache-dependency-path: app/frontend/package-lock.json + +# - name: Install dependencies +# working-directory: ./app/frontend +# run: npm ci + +# - name: Build Next.js +# working-directory: ./app/frontend +# run: npm run build +# env: +# NEXT_PUBLIC_ RustAcademy_API_URL: ${{ secrets.NEXT_PUBLIC_ RustAcademy_API_URL }} +# NEXT_PUBLIC_SITE_URL: ${{ secrets.NEXT_PUBLIC_SITE_URL }} +# NEXT_PUBLIC_STELLAR_NETWORK: ${{ secrets.NEXT_PUBLIC_STELLAR_NETWORK }} +# NEXT_PUBLIC_ERROR_REPORTING_ENABLED: ${{ secrets.NEXT_PUBLIC_ERROR_REPORTING_ENABLED }} +# NEXT_PUBLIC_APP_VERSION: ${{ secrets.NEXT_PUBLIC_APP_VERSION }} + +# security-scan: +# name: Security Scan +# runs-on: ubuntu-latest + +# steps: +# - name: Checkout code +# uses: actions/checkout@v4 + +# - name: Run npm audit +# working-directory: ./app/frontend +# run: npm audit --audit-level=moderate +# continue-on-error: true + +# - name: Check for mixed content +# run: | +# echo "Checking for hardcoded HTTP URLs in frontend code..." +# # Search for HTTP URLs, filter out localhost, and check if any remain +# if grep -r "http://" app/frontend/src --include="*.ts" --include="*.tsx" --exclude-dir=node_modules 2>/dev/null | grep -v "http://localhost" > /dev/null 2>&1; then +# echo "❌ Found hardcoded HTTP URLs (non-localhost). This may cause mixed-content issues in production." +# grep -r "http://" app/frontend/src --include="*.ts" --include="*.tsx" --exclude-dir=node_modules | grep -v "http://localhost" +# exit 1 +# else +# echo "✅ No problematic HTTP URLs found" +# fi diff --git a/.github/workflows/mobile-release.yml b/.github/workflows/mobile-release.yml deleted file mode 100644 index c7f03db175..0000000000 --- a/.github/workflows/mobile-release.yml +++ /dev/null @@ -1,104 +0,0 @@ -name: Mobile Release Pipeline - -on: - push: - tags: - - 'v*' - workflow_dispatch: - inputs: - runMatrix: - description: 'Build dev, staging, and production internal artifacts' - required: false - default: 'true' - -jobs: - production-release: - if: github.event_name == 'push' - name: Production Release Tag Build - runs-on: ubuntu-latest - defaults: - run: - working-directory: app/mobile - strategy: - matrix: - platform: [android, ios] - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - - - name: Install pnpm - uses: pnpm/action-setup@v4 - with: - run_install: false - - - name: Install dependencies - run: pnpm install --no-frozen-lockfile - - - name: Lint and type-check - run: pnpm run build - - - name: Install EAS CLI - run: npm install -g eas-cli@latest - - - name: Authenticate EAS - env: - EAS_TOKEN: ${{ secrets.EAS_TOKEN }} - run: eas login --token "$EAS_TOKEN" - - - name: Build production internal artifact - env: - BUILD_NUMBER: ${{ github.run_number }} - GIT_TAG: ${{ github.ref_name }} - run: | - eas build --platform ${{ matrix.platform }} --profile production --non-interactive - - environment-matrix: - if: github.event_name == 'workflow_dispatch' - name: Environment Matrix Internal Builds - runs-on: ubuntu-latest - defaults: - run: - working-directory: app/mobile - strategy: - matrix: - platform: [android, ios] - app_env: [dev, staging, production] - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - - - name: Install pnpm - uses: pnpm/action-setup@v4 - with: - run_install: false - - - name: Install dependencies - run: pnpm install --no-frozen-lockfile - - - name: Lint and type-check - run: pnpm run build - - - name: Install EAS CLI - run: npm install -g eas-cli@latest - - - name: Authenticate EAS - env: - EAS_TOKEN: ${{ secrets.EAS_TOKEN }} - run: eas login --token "$EAS_TOKEN" - - - name: Build internal artifact - env: - BUILD_NUMBER: ${{ github.run_number }} - GIT_TAG: ${{ github.ref_name }} - run: | - eas build --platform ${{ matrix.platform }} --profile ${{ matrix.app_env }} --non-interactive diff --git a/.github/workflows/smoke-tests.yml b/.github/workflows/smoke-tests.yml deleted file mode 100644 index 26017400d3..0000000000 --- a/.github/workflows/smoke-tests.yml +++ /dev/null @@ -1,171 +0,0 @@ -name: Deployment Smoke Tests - -on: [workflow_dispatch] - -jobs: - disabled: - runs-on: ubuntu-latest - steps: - - run: echo "This workflow is currently disabled." - -# name: Deployment Smoke Tests - -# on: -# deployment: -# status: success -# workflow_dispatch: -# inputs: -# environment: -# description: 'Environment to test' -# required: true -# default: 'production' -# type: choice -# options: -# - production -# - staging -# - development - -# jobs: -# smoke-tests: -# name: Run Smoke Tests -# runs-on: ubuntu-latest -# defaults: -# run: -# working-directory: app/backend - -# steps: -# - name: Checkout code -# uses: actions/checkout@v4 - -# - name: Setup Node.js -# uses: actions/setup-node@v4 -# with: -# node-version: '20' - -# - name: Install pnpm -# uses: pnpm/action-setup@v4 -# with: -# run_install: false - -# - name: Install dependencies -# run: pnpm install --no-frozen-lockfile - -# - name: Build application -# run: pnpm run build - -# - name: Determine environment URL -# id: env-url -# run: | -# if [ "${{ github.event_name }}" == "deployment" ]; then -# echo "url=${{ github.event.deployment.environment_url }}" >> $GITHUB_OUTPUT -# echo "env=${{ github.event.deployment.environment }}" >> $GITHUB_OUTPUT -# else -# if [ "${{ inputs.environment }}" == "production" ]; then -# echo "url=https://api. RustAcademy.io" >> $GITHUB_OUTPUT -# echo "env=production" >> $GITHUB_OUTPUT -# elif [ "${{ inputs.environment }}" == "staging" ]; then -# echo "url=https://api-staging. RustAcademy.io" >> $GITHUB_OUTPUT -# echo "env=staging" >> $GITHUB_OUTPUT -# else -# echo "url=http://localhost:3000" >> $GITHUB_OUTPUT -# echo "env=development" >> $GITHUB_OUTPUT -# fi -# fi - -# - name: Wait for deployment to be ready -# run: | -# echo "Waiting for ${{ steps.env-url.outputs.env }} environment at ${{ steps.env-url.outputs.url }}" -# timeout=300 -# elapsed=0 -# while [ $elapsed -lt $timeout ]; do -# if curl -sf "${{ steps.env-url.outputs.url }}/health" > /dev/null 2>&1; then -# echo "Deployment is ready!" -# exit 0 -# fi -# echo "Waiting for deployment... (${elapsed}s/${timeout}s)" -# sleep 10 -# elapsed=$((elapsed + 10)) -# done -# echo "Deployment did not become ready within ${timeout}s" -# exit 1 - -# - name: Run smoke tests -# id: smoke-tests -# env: -# SMOKE_TEST_BASE_URL: ${{ steps.env-url.outputs.url }} -# NODE_ENV: test -# STELLAR_NETWORK: ${{ steps.env-url.outputs.env == 'production' && 'mainnet' || 'testnet' }} -# run: | -# echo "Running smoke tests against ${{ steps.env-url.outputs.url }}" -# echo "Environment: ${{ steps.env-url.outputs.env }}" - -# # Run smoke tests with Jest -# pnpm run test:e2e -- test/smoke.e2e-spec.ts --testTimeout=30000 --verbose - -# # Run Soroban/Horizon specific smoke tests -# pnpm run test:e2e -- test/smoke-soroban-horizon.e2e-spec.ts --testTimeout=30000 --verbose -# continue-on-error: true - -# - name: Generate smoke test report -# if: always() -# id: test-report -# run: | -# # Create a summary report -# echo "# Smoke Test Report" >> $GITHUB_STEP_SUMMARY -# echo "" >> $GITHUB_STEP_SUMMARY -# echo "**Environment:** ${{ steps.env-url.outputs.env }}" >> $GITHUB_STEP_SUMMARY -# echo "**URL:** ${{ steps.env-url.outputs.url }}" >> $GITHUB_STEP_SUMMARY -# echo "**Timestamp:** $(date -u +"%Y-%m-%d %H:%M:%S UTC")" >> $GITHUB_STEP_SUMMARY -# echo "" >> $GITHUB_STEP_SUMMARY - -# if [ "${{ steps.smoke-tests.outcome }}" == "success" ]; then -# echo "## ✅ All smoke tests passed" >> $GITHUB_STEP_SUMMARY -# echo "" >> $GITHUB_STEP_SUMMARY -# echo "The deployment is healthy and all critical endpoints are functioning correctly." >> $GITHUB_STEP_SUMMARY -# exit 0 -# else -# echo "## ❌ Smoke tests failed" >> $GITHUB_STEP_SUMMARY -# echo "" >> $GITHUB_STEP_SUMMARY -# echo "One or more smoke tests failed. Please review the logs above for details." >> $GITHUB_STEP_SUMMARY -# echo "" >> $GITHUB_STEP_SUMMARY -# echo "### Critical Checks:" >> $GITHUB_STEP_SUMMARY -# echo "- Health endpoint: `/health`" >> $GITHUB_STEP_SUMMARY -# echo "- Readiness endpoint: `/ready`" >> $GITHUB_STEP_SUMMARY -# echo "- Link metadata: `/links/metadata`" >> $GITHUB_STEP_SUMMARY -# echo "- Username search: `/username/search`" >> $GITHUB_STEP_SUMMARY -# echo "- Soroban RPC connectivity" >> $GITHUB_STEP_SUMMARY -# echo "- Horizon connectivity" >> $GITHUB_STEP_SUMMARY -# exit 1 -# fi - -# - name: Comment on deployment (if triggered by deployment) -# if: github.event_name == 'deployment' && always() -# uses: actions/github-script@v7 -# with: -# script: | -# const { data: deployment } = await github.rest.repos.getDeployment({ -# owner: context.repo.owner, -# repo: context.repo.repo, -# deployment_id: context.event.deployment.id, -# }); - -# const status = '${{ steps.smoke-tests.outcome }}' === 'success' ? 'success' : 'failure'; - -# await github.rest.repos.createDeploymentStatus({ -# owner: context.repo.owner, -# repo: context.repo.repo, -# deployment_id: context.event.deployment.id, -# state: status, -# environment_url: deployment.environment_url, -# log_url: `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`, -# description: status === 'success' -# ? 'Smoke tests passed - deployment is healthy' -# : 'Smoke tests failed - deployment may have issues', -# }); - -# - name: Notify on failure -# if: failure() -# run: | -# echo "Smoke tests failed! Deployment may have issues." -# echo "Please review the logs and check the deployment status." -# exit 1 diff --git a/.github/workflows/soroban-e2e.yml b/.github/workflows/soroban-e2e.yml deleted file mode 100644 index a45ab8c525..0000000000 --- a/.github/workflows/soroban-e2e.yml +++ /dev/null @@ -1,45 +0,0 @@ -# name: Backend Soroban E2E (Testnet) - -# on: -# workflow_dispatch: {} -# push: -# branches: -# - main - -# jobs: -# e2e-testnet: -# name: E2E Testnet Contract Flows -# runs-on: ubuntu-latest - -# steps: -# - name: Checkout code -# uses: actions/checkout@v4 - -# - name: Setup Node.js -# uses: actions/setup-node@v4 -# with: -# node-version: "20" - -# - name: Install pnpm -# uses: pnpm/action-setup@v4 -# with: -# run_install: false - -# - name: Install dependencies -# run: pnpm install --no-frozen-lockfile - -# - name: Build shared packages -# run: pnpm turbo run build --filter=stellar-sdk - -# - name: Run Soroban E2E Flow -# env: -# APP_ENV: staging -# STELLAR_NETWORK: testnet -# NEXT_PUBLIC_STELLAR_NETWORK: testnet -# E2E_WALLET_SECRET: ${{ secrets.E2E_WALLET_SECRET }} -# RustAcademy_CONTRACT_ID: ${{ secrets. RustAcademy_CONTRACT_ID }} -# SUPABASE_URL: ${{ secrets.SUPABASE_URL }} -# SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} -# run: | -# echo "Starting E2E tests against Soroban testnet..." -# pnpm turbo run test:e2e --filter=app/backend -- test/soroban-flow.e2e-spec.ts diff --git a/BackendAcademy/.env.example b/BackendAcademy/.env.example index 5299f583e1..77845ce035 100644 --- a/BackendAcademy/.env.example +++ b/BackendAcademy/.env.example @@ -1,55 +1,22 @@ # Server -PORT=3000 -NODE_ENV=development +PORT=4001 +SWAGGER_ENABLED=true -# Database -# REQUIRED in production. Local/development uses the default below when omitted. -DATABASE_URL=postgresql://postgres:password@localhost:5432/rustacademy -# Schema synchronization: MUSt be false in production/staging. Migrations are the only schema-change path. -DATABASE_SYNCHRONIZE=false +# Rate limiting +THROTTLE_TTL_MS=60000 +THROTTLE_LIMIT=100 -# Redis -# REQUIRED in production. Defaults to localhost in development/test. -REDIS_HOST=localhost -REDIS_PORT=6379 -REDIS_PASSWORD= +# Database (Supabase / PostgreSQL) +DATABASE_URL=postgresql://postgres:password@localhost:5432/rustacademy +SUPABASE_URL=https://your-project.supabase.co +SUPABASE_SERVICE_ROLE_KEY=your-service-role-key # Auth -# REQUIRED in production with >= 32 chars and NOT a placeholder value. -# Development/test use explicit non-production defaults when omitted. -JWT_SECRET=change_me_in_production -# Maximum allowed clock skew (seconds) tolerated when verifying JWT -# exp/nbf claims. Distributed clocks can drift, causing premature expiry -# or acceptance of expired tokens. Bounded to 0..120 by config validation. -JWT_CLOCK_SKEW_SECONDS=30 - -# Signing secret for signed asset download URLs. -# REQUIRED in production; an empty value makes signed URLs forgeable. -ASSET_SIGNING_SECRET=change_me_in_production - -# API Keys -API_KEY_SECRET=change_me_in_production - -# CORS -# "*" or a comma-separated list of origins. -CORS_ORIGIN=http://localhost:3000 +JWT_SECRET=change-me -# AI Provider -AI_PROVIDER=mock # claude | openai | mock -ANTHROPIC_API_KEY= # Required when AI_PROVIDER=claude -OPENAI_API_KEY= # Required when AI_PROVIDER=openai -AI_MODE= # Model override (optional) -AI_MAX_TOKENS=4096 -AI_TEMPERATURE=0.7 -# BA-078: Retry policy for transient AI provider errors (429/5xx) -AI_RETTY_MAX_ATTEMPT3=3 -AI_RETTY_BASE_DELAY_MS=250 -AI_RETTY_MAX_DELAY_MS=5000 +# Stellar +STELLAR_NETWORK=testnet +STELLAR_HORIZON_URL=https://horizon-testnet.stellar.org -# Static & uploaded assets -ASSETS_UPLOAD_DIR=./data/uploads # Where uploaded assets are persisted on disk -ASSETS_MAX_SIZE_MB=10 # Per-file upload size limit in megabytes -ASSETS_MAX_TOTAL_MB=1024 # Aggregate byte quota across all stored assets (MB) -ASSETS_MAXC_COUNT=10000 # Maximum number of assets retained by the registry -ASSETS_BASE_URL=/api/v1/assets # Base URL advertised inside asset metadata -ASSETS_STATIC_DIR=./public # Read-only static asset directory served at /static +# AI Mentor +ANTHROPIC_API_KEY= diff --git a/BackendAcademy/.eslintrc.js b/BackendAcademy/.eslintrc.js deleted file mode 100644 index 097220c7fe..0000000000 --- a/BackendAcademy/.eslintrc.js +++ /dev/null @@ -1,23 +0,0 @@ -module.exports = { - parser: '@typescript-eslint/parser', - parserOptions: { - project: 'tsconfig.json', - sourceType: 'module', - }, - plugins: ['@typescript-eslint/eslint-plugin'], - extends: [ - 'plugin:@typescript-eslint/recommended', - ], - root: true, - env: { - node: true, - jest: true, - }, - ignorePatterns: ['.eslintrc.js'], - rules: { - '@typescript-eslint/interface-name-prefix': 'off', - '@typescript-eslint/explicit-function-return-type': 'off', - '@typescript-eslint/explicit-module-boundary-types': 'off', - '@typescript-eslint/no-explicit-any': 'off', - }, -}; \ No newline at end of file diff --git a/BackendAcademy/.gitignore b/BackendAcademy/.gitignore index b0fe3af9b2..1017768ac7 100644 --- a/BackendAcademy/.gitignore +++ b/BackendAcademy/.gitignore @@ -1,78 +1,6 @@ node_modules - - -# compiled output -/dist -/node_modules -/build - -# Logs -logs -*.log -npm-debug.log* -pnpm-debug.log* -yarn-debug.log* -yarn-error.log* -lerna-debug.log* - -# OS -.DS_Store - -# Tests -/coverage -/.nyc_output - -# IDEs and editors -/.idea -.project -.classpath -.c9/ -*.launch -.settings/ -*.sublime-workspace - -# IDE - VSCode -.vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json - -# dotenv environment variable files +dist +coverage .env .env.local -.env.*.local -.env.development.local -.env.test.local -.env.production.local -.env.backup -.env.bak -.env.backup.* - -# Secrets and sensitive files -.vault/ -vault-data/ -*.pem -*.key -*.pub -*.pfx -*.p12 -secrets.json -credentials.json -service-account-key.json - -# Secret scanning -.secrets.baseline - -# temp directory -.temp -.tmp - -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -# Diagnostic reports (https://nodejs.org/api/report.html) -report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json +*.log diff --git a/BackendAcademy/.prettierrc b/BackendAcademy/.prettierrc deleted file mode 100644 index e78a706dec..0000000000 --- a/BackendAcademy/.prettierrc +++ /dev/null @@ -1,7 +0,0 @@ -{ - "singleQuote": true, - "trailingComma": "all", - "printWidth": 100, - "tabWidth": 2, - "semi": true -} diff --git a/BackendAcademy/BackendAcademy/.gitignore b/BackendAcademy/BackendAcademy/.gitignore deleted file mode 100644 index c2658d7d1b..0000000000 --- a/BackendAcademy/BackendAcademy/.gitignore +++ /dev/null @@ -1 +0,0 @@ -node_modules/ diff --git a/BackendAcademy/BackendAcademy/pay.md b/BackendAcademy/BackendAcademy/pay.md deleted file mode 100644 index 356e0b475d..0000000000 --- a/BackendAcademy/BackendAcademy/pay.md +++ /dev/null @@ -1,269 +0,0 @@ -# BackendAcademy - -RustAcademy backend module — placeholder for future NestJS backend implementation. - -## Getting Started - -```bash -pnpm install -pnpm run dev -``` - -## Structure - -- `src/` — Application source code (NestJS modules, controllers, services) -- `test/` — Test files - -See `app/backend/` for the primary backend implementation and conventions. - ---- - -# Backend Guide for shadcn/ui - -When integrating a frontend built with **shadcn/ui**, backend endpoints should provide consistent and predictable JSON responses to simplify component integration. - -## Success Response - -```json -{ - "success": true, - "data": {}, - "message": "Request completed successfully" -} -``` - -## Error Response - -```json -{ - "success": false, - "error": { - "code": "VALIDATION_ERROR", - "message": "Invalid request", - "fields": { - "email": "Email is required" - } - } -} -``` - -## Recommendations - -- Return consistent response structures. -- Use proper HTTP status codes. -- Include field-level validation errors. -- Support pagination for table components. -- Keep payloads predictable for frontend consumers. -- Avoid exposing internal implementation details. - -## Example Table Response - -```json -{ - "success": true, - "data": { - "items": [], - "pagination": { - "page": 1, - "limit": 10, - "total": 0 - } - } -} -``` - -## Example Select Response - -```json -{ - "success": true, - "data": [ - { - "label": "Admin", - "value": "admin" - }, - { - "label": "User", - "value": "user" - } - ] -} -``` -``` - ---- - -# AI Hints — How They Are Generated and Used - -The AI subsystem lives in `src/ai/` and exposes three capabilities to the rest of the platform: **chat-based mentoring**, **graduated task hints**, and **AI pre-scoring of code submissions**. This section focuses on hints specifically, then covers the supporting pieces. - -## Architecture Overview - -``` -Client (frontend / mobile) - │ - ▼ - POST /ai/hint ← AiController - │ - ▼ - AiService.getHint() ← business logic, hint store, difficulty routing - │ - ├── [hint found in store] → return stored hint + increment usedCount - └── [no hint in store] → return generic fallback message -``` - -When `AI_PROVIDER=claude` (or `openai`) is set, the `processChatRequest` path uses the provider to generate dynamic responses. The hint path currently uses a pre-seeded in-memory store — dynamic AI-generated hints are the planned Phase 2 upgrade (see below). - -## Request / Response Shape - -### POST `/ai/hint` - -**Request body** (`GetHintDto`): - -```json -{ - "challengeId": "sample-challenge-001", - "userId": "user-abc", - "difficulty": 2 -} -``` - -| Field | Type | Required | Description | -|---|---|---|---| -| `challengeId` | string | ✅ | Identifier of the task or challenge | -| `userId` | string | ✅ | Identifier of the requesting learner | -| `difficulty` | number | ❌ | Hint tier to request (1 = most gentle, 3 = most specific). Defaults to 1 | - -**Response body** (`AiHintResponse`): - -```json -{ - "hint": "Consider edge cases - empty, null, or out-of-range inputs.", - "hintId": "3f2c1a...", - "difficulty": 2 -} -``` - -| Field | Type | Description | -|---|---|---| -| `hint` | string | The hint text shown to the learner | -| `hintId` | string | UUID of the specific hint record (for analytics) | -| `difficulty` | number | Difficulty tier that was actually served (may differ from request if the requested tier was unavailable) | - -If no hints exist for the given `challengeId`, the API returns HTTP 200 with a generic fallback: - -```json -{ - "hint": "No hints available for this challenge yet. Keep trying!", - "hintId": "", - "difficulty": 1 -} -``` - -## Hint Difficulty Tiers - -Hints are designed to be **graduated** — each tier reveals progressively more information so learners are guided without being spoiled. - -| Tier | Intent | Example | -|---|---|---| -| **1** — Conceptual nudge | Reframe the problem; no implementation detail | `"Start by understanding the problem requirements thoroughly."` | -| **2** — Edge-case reminder | Point toward gotchas without giving code | `"Consider edge cases — empty, null, or out-of-range inputs."` | -| **3** — Algorithmic direction | Suggest an approach or pattern | `"Implement brute-force first, then optimize."` | - -When a learner requests tier 2 but only tier 1 is stored, `AiService.getHint()` falls back to the first available hint for that challenge rather than returning nothing. - -## How Hints Are Stored and Seeded - -`AiService` maintains an in-memory `Map` called `hints`. On startup, `initializeSampleHints()` pre-populates it with the three sample tiers for `"sample-challenge-001"`. - -``` -Hint { - id – UUID - challengeId – which challenge this hint belongs to - hint – hint text - difficulty – tier number (1–3) - usedCount – incremented each time the hint is served -} -``` - -`usedCount` is tracked so the analytics layer can identify which hints learners reach most often — a signal that difficulty calibration may need adjustment on a given challenge. - -In production, this in-memory store will be replaced by a database table. The service interface (`getHint`, `AiHintResponse`) will remain unchanged. - -## AI Provider Wiring - -The hint system currently runs entirely off the in-memory store, so it works without any API key configured. The full AI-powered chat path uses a pluggable provider selected at startup: - -``` -AI_PROVIDER=claude → ClaudeProvider (Anthropic Messages API) -AI_PROVIDER=openai → OpenaiProvider (OpenAI Chat Completions API) -(unset / other) → null provider (deterministic fallback responses) -``` - -The factory is defined in `AiModule` and injects the chosen provider into `AiService` via the `AI_PROVIDER` token: - -```typescript -// src/ai/ai.module.ts -const aiProviderFactory = { - provide: AI_PROVIDER, - useFactory: (configService: ConfigService) => { - const provider = configService.get('AI_PROVIDER'); - if (provider === 'openai') return new OpenaiProvider(configService); - if (provider === 'claude') return new ClaudeProvider(configService); - return null; // ← fallback, no external calls - }, - inject: [ConfigService], -}; -``` - -`ClaudeProvider` calls `POST https://api.anthropic.com/v1/messages` using the model specified by `AI_MODEL` (default: `claude-sonnet-4-20250514`), with `AI_MAX_TOKENS` (default: 4096) and `AI_TEMPERATURE` (default: 0.7). - -## Environment Variables - -| Variable | Default | Description | -|---|---|---| -| `AI_PROVIDER` | _(none)_ | `claude` or `openai`; omit to use offline fallback | -| `ANTHROPIC_API_KEY` | _(none)_ | Required when `AI_PROVIDER=claude` | -| `OPENAI_API_KEY` | _(none)_ | Required when `AI_PROVIDER=openai` | -| `AI_MODEL` | `claude-sonnet-4-20250514` | Model name passed to the provider | -| `AI_MAX_TOKENS` | `4096` | Maximum tokens per AI response | -| `AI_TEMPERATURE` | `0.7` | Sampling temperature (0 = deterministic, 1 = creative) | - -Copy `.env.example` and fill in the relevant keys: - -```bash -cp .env.example .env -``` - -## Related Endpoints - -| Method | Path | Description | -|---|---|---| -| `POST` | `/ai/hint` | Fetch a graduated hint for a challenge | -| `POST` | `/ai/chat` | Send a free-form message to the AI Mentor | -| `POST` | `/ai/pre-score` | Submit code for an AI pre-score before tutor review | -| `GET` | `/ai/history/:userId` | Retrieve a user's full chat history | - -### POST `/ai/chat` - -Sends a conversational message to the AI Mentor. The system prompt is fixed to `"You are a helpful Rust programming tutor."` The full message history per user is stored in memory and returned by `GET /ai/history/:userId`. - -Request body fields: `message` (string), `userId` (string), optional `context` (object). - -### POST `/ai/pre-score` - -Performs a static analysis pre-score on submitted Rust code before it enters the tutor review queue. The heuristic checks for: - -- Presence of `fn main()` (+15 pts) -- Use of functions with non-trivial line count (+15 pts) -- Presence of comments (+10 pts) -- Code length > 20 lines (+10 pts) - -Base score is 50. Final score is clamped to [0, 100]. A `confidence` of `0.7` is always reported in the current placeholder — this will be replaced by a model-calibrated confidence value once the full AI grading pipeline is wired in. - -## Planned Enhancements (Phase 2) - -- **Dynamic hint generation** — when no hint is stored for a `challengeId`, fall through to the AI provider to generate one on-demand using the task description as context. -- **Per-user hint gating** — track how many hints a learner has consumed per challenge and reduce XP payout accordingly. -- **Database persistence** — migrate `hints` and `chatHistory` maps to PostgreSQL via the Supabase client. -- **Streaming responses** — switch the chat endpoint to Server-Sent Events for real-time token streaming. diff --git a/BackendAcademy/BackendAcademy/read.md b/BackendAcademy/BackendAcademy/read.md deleted file mode 100644 index bad562f6ae..0000000000 --- a/BackendAcademy/BackendAcademy/read.md +++ /dev/null @@ -1,2 +0,0 @@ -hhhhhhhhhhdddd -fff \ No newline at end of file diff --git a/BackendAcademy/BackendAcademy/src/courses/courses.controller.ts b/BackendAcademy/BackendAcademy/src/courses/courses.controller.ts deleted file mode 100644 index e05e5874a8..0000000000 --- a/BackendAcademy/BackendAcademy/src/courses/courses.controller.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from '@nestjs/common'; -import { CoursesController } from './courses.controller'; -import { CoursesService } from './courses.service'; - -@Module({ - controllers: [CoursesController], - providers: [CoursesService], - exports: [CoursesService], -}) -export class CoursesModule {} \ No newline at end of file diff --git a/BackendAcademy/BackendAcademy/src/courses/courses.module.ts b/BackendAcademy/BackendAcademy/src/courses/courses.module.ts deleted file mode 100644 index 4eb17c3e1e..0000000000 --- a/BackendAcademy/BackendAcademy/src/courses/courses.module.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Module } from '@nestjs/common'; -import { CoursesController } from './courses.controller'; -import { CoursesService } from './courses.service'; - -/** - * CoursesModule - * - * Groups together everything related to the "courses" feature: - * the controller (handles HTTP requests) and the service (business logic). - */ -@Module({ - // Controllers that belong to this module and handle incoming requests - controllers: [CoursesController], - - // Providers (services) available for dependency injection within this module - providers: [CoursesService], - - // Providers exported so other modules can import and use CoursesService - exports: [CoursesService], -}) -export class CoursesModule {} diff --git a/BackendAcademy/BackendAcademy/src/courses/courses.service.spec.ts b/BackendAcademy/BackendAcademy/src/courses/courses.service.spec.ts deleted file mode 100644 index 401a99306e..0000000000 --- a/BackendAcademy/BackendAcademy/src/courses/courses.service.spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { NotFoundException } from '@nestjs/common'; -import { CoursesService } from './courses.service'; - -describe('CoursesService', () => { - // Holds the instance of the service under test - let service: CoursesService; - - // Runs before each test: builds a fresh testing module and resolves the service - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [CoursesService], - }).compile(); - - service = module.get(CoursesService); - }); - - // Sanity check: the service should be instantiated correctly by the DI container - it('should be defined', () => { - expect(service).toBeDefined(); - }); - - // create() should return the submitted data merged with a generated id - it('create() returns the dto with an id', () => { - const result = service.create({ - title: 'Rust Basics', - description: 'An intro to Rust programming language.', - }); - - // The returned object should contain the same title we passed in - expect(result).toMatchObject({ title: 'Rust Basics' }); - - // An id should have been generated for the new course - expect(result.id).toBeDefined(); - }); - - // findAll() should always return an array (even if empty) - it('findAll() returns an array', () => { - expect(Array.isArray(service.findAll())).toBe(true); - }); - - // findOne() should throw a NotFoundException when the course doesn't exist - it('findOne() throws NotFoundException for unknown id', () => { - expect(() => service.findOne(999)).toThrow(NotFoundException); - }); -}); diff --git a/BackendAcademy/BackendAcademy/src/courses/courses.service.ts b/BackendAcademy/BackendAcademy/src/courses/courses.service.ts deleted file mode 100644 index 6ec567889d..0000000000 --- a/BackendAcademy/BackendAcademy/src/courses/courses.service.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; -import { CreateCourseDto } from './dto/create-course.dto'; -import { UpdateCourseDto } from './dto/update-course.dto'; - -/** - * CoursesService - * - * Contains the business logic for managing courses (CRUD operations). - * Currently uses stub/placeholder logic; real persistence (e.g. via a - * repository or ORM) still needs to be implemented. - */ -@Injectable() -export class CoursesService { - /** - * Creates a new course. - * TODO: persist the course via a repository instead of just returning it. - */ - create(dto: CreateCourseDto) { - // TODO: persist via repository - // Temporary: fake an id using the current timestamp - return { ...dto, id: Date.now() }; - } - - /** - * Retrieves all courses. - * TODO: fetch the list from a repository/database. - */ - findAll() { - // TODO: query repository - // Temporary: no data source yet, so return an empty array - return []; - } - - /** - * Retrieves a single course by id. - * TODO: fetch the course from a repository/database. - * Throws a NotFoundException if the course doesn't exist. - */ - findOne(id: number) { - // TODO: query repository - // Temporary: no data source yet, so this is always null - const course = null; - - // Guard clause: bail out with a 404-style error if nothing was found - if (!course) throw new NotFoundException(`Course #${id} not found`); - - return course; - } - - /** - * Updates an existing course by id. - * TODO: look up the existing course, then persist the merged changes. - */ - update(id: number, dto: UpdateCourseDto) { - // TODO: query then persist - // Temporary: just echo back the id and updated fields - return { id, ...dto }; - } - - /** - * Removes a course by id. - * TODO: actually delete the record from the repository/database. - */ - remove(id: number) { - // TODO: delete from repository - // Temporary: just acknowledge the deletion request - return { deleted: id }; - } -} diff --git a/BackendAcademy/BackendAcademy/src/courses/create-course.dto.ts b/BackendAcademy/BackendAcademy/src/courses/create-course.dto.ts deleted file mode 100644 index e83588e98b..0000000000 --- a/BackendAcademy/BackendAcademy/src/courses/create-course.dto.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { IsString, IsOptional, MinLength, IsUrl, IsInt, Min } from 'class-validator'; - -export class CreateCourseDto { - @IsString() - @MinLength(3) - title: string; - - @IsString() - @MinLength(10) - description: string; - - @IsOptional() - @IsUrl() - imageUrl?: string; - - @IsOptional() - @IsInt() - @Min(0) - durationMinutes?: number; - - @IsOptional() - @IsString() - level?: 'beginner' | 'intermediate' | 'advanced'; -} \ No newline at end of file diff --git a/BackendAcademy/BackendAcademy/src/courses/update-course.dto.ts b/BackendAcademy/BackendAcademy/src/courses/update-course.dto.ts deleted file mode 100644 index e04de70a21..0000000000 --- a/BackendAcademy/BackendAcademy/src/courses/update-course.dto.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { PartialType } from '@nestjs/mapped-types'; -import { CreateCourseDto } from './create-course.dto'; - -export class UpdateCourseDto extends PartialType(CreateCourseDto) {} \ No newline at end of file diff --git a/BackendAcademy/BackendAcademy/src/users/create-user.dto.ts b/BackendAcademy/BackendAcademy/src/users/create-user.dto.ts deleted file mode 100644 index c028929654..0000000000 --- a/BackendAcademy/BackendAcademy/src/users/create-user.dto.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { IsEmail, IsString, MinLength, IsOptional } from 'class-validator'; - -export class CreateUserDto { - @IsString() - @MinLength(2) - name: string; - - @IsEmail() - email: string; - - @IsString() - @MinLength(8) - password: string; - - @IsOptional() - @IsString() - role?: string; -} \ No newline at end of file diff --git a/BackendAcademy/BackendAcademy/src/users/update-user.dto.ts b/BackendAcademy/BackendAcademy/src/users/update-user.dto.ts deleted file mode 100644 index fd694a8c10..0000000000 --- a/BackendAcademy/BackendAcademy/src/users/update-user.dto.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { PartialType } from '@nestjs/mapped-types'; -import { CreateUserDto } from './create-user.dto'; - -export class UpdateUserDto extends PartialType(CreateUserDto) {} \ No newline at end of file diff --git a/BackendAcademy/BackendAcademy/src/users/users.controller.ts b/BackendAcademy/BackendAcademy/src/users/users.controller.ts deleted file mode 100644 index bca5662557..0000000000 --- a/BackendAcademy/BackendAcademy/src/users/users.controller.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Controller, Get, Post, Patch, Delete, Body, Param, ParseIntPipe, HttpCode, HttpStatus } from '@nestjs/common'; -import { UsersService } from './users.service'; -import { CreateUserDto } from './dto/create-user.dto'; -import { UpdateUserDto } from './dto/update-user.dto'; - -@Controller('users') -export class UsersController { - constructor(private readonly usersService: UsersService) {} - - @Post() - create(@Body() dto: CreateUserDto) { - return this.usersService.create(dto); - } - - @Get() - findAll() { - return this.usersService.findAll(); - } - - @Get(':id') - findOne(@Param('id', ParseIntPipe) id: number) { - return this.usersService.findOne(id); - } - - @Patch(':id') - update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateUserDto) { - return this.usersService.update(id, dto); - } - - @Delete(':id') - @HttpCode(HttpStatus.NO_CONTENT) - remove(@Param('id', ParseIntPipe) id: number) { - return this.usersService.remove(id); - } -} \ No newline at end of file diff --git a/BackendAcademy/BackendAcademy/src/users/users.module.ts b/BackendAcademy/BackendAcademy/src/users/users.module.ts deleted file mode 100644 index f8d31fa644..0000000000 --- a/BackendAcademy/BackendAcademy/src/users/users.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from '@nestjs/common'; -import { UsersController } from './users.controller'; -import { UsersService } from './users.service'; - -@Module({ - controllers: [UsersController], - providers: [UsersService], - exports: [UsersService], -}) -export class UsersModule {} \ No newline at end of file diff --git a/BackendAcademy/BackendAcademy/src/users/users.service.spec.ts b/BackendAcademy/BackendAcademy/src/users/users.service.spec.ts deleted file mode 100644 index 383abb00cf..0000000000 --- a/BackendAcademy/BackendAcademy/src/users/users.service.spec.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { NotFoundException } from '@nestjs/common'; -import { UsersService } from './users.service'; - -describe('UsersService', () => { - let service: UsersService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [UsersService], - }).compile(); - - service = module.get(UsersService); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); - - it('create() returns the dto with an id', () => { - const result = service.create({ name: 'Alice', email: 'alice@example.com', password: 'secret123' }); - expect(result).toMatchObject({ name: 'Alice', email: 'alice@example.com' }); - expect(result.id).toBeDefined(); - }); - - it('findAll() returns an array', () => { - expect(Array.isArray(service.findAll())).toBe(true); - }); - - it('findOne() throws NotFoundException for unknown id', () => { - expect(() => service.findOne(999)).toThrow(NotFoundException); - }); -}); \ No newline at end of file diff --git a/BackendAcademy/BackendAcademy/src/users/users.service.ts b/BackendAcademy/BackendAcademy/src/users/users.service.ts deleted file mode 100644 index 247db414e0..0000000000 --- a/BackendAcademy/BackendAcademy/src/users/users.service.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; -import { CreateUserDto } from './dto/create-user.dto'; -import { UpdateUserDto } from './dto/update-user.dto'; - -@Injectable() -export class UsersService { - create(dto: CreateUserDto) { - // TODO: persist via repository - return { ...dto, id: Date.now() }; - } - - findAll() { - // TODO: query repository - return []; - } - - findOne(id: number) { - // TODO: query repository - const user = null; - if (!user) throw new NotFoundException(`User #${id} not found`); - return user; - } - - update(id: number, dto: UpdateUserDto) { - // TODO: query then persist - return { id, ...dto }; - } - - remove(id: number) { - // TODO: delete from repository - return { deleted: id }; - } -} \ No newline at end of file diff --git a/BackendAcademy/BackendAcademy/transa.md b/BackendAcademy/BackendAcademy/transa.md deleted file mode 100644 index b1ffbc3e85..0000000000 --- a/BackendAcademy/BackendAcademy/transa.md +++ /dev/null @@ -1,15 +0,0 @@ -RustAcademy backend module — placeholder for future NestJS backend implementation. - -## Getting Started - -```bash -pnpm install -pnpm run dev -``` - -## Structure - -- `src/` — Application source code (NestJS modules, controllers, services) -- `test/` — Test files - -See `app/backend/` for the primary backend implementation and conventions. diff --git a/BackendAcademy/BackendAcademy/webhook.md b/BackendAcademy/BackendAcademy/webhook.md deleted file mode 100644 index 356e0b475d..0000000000 --- a/BackendAcademy/BackendAcademy/webhook.md +++ /dev/null @@ -1,269 +0,0 @@ -# BackendAcademy - -RustAcademy backend module — placeholder for future NestJS backend implementation. - -## Getting Started - -```bash -pnpm install -pnpm run dev -``` - -## Structure - -- `src/` — Application source code (NestJS modules, controllers, services) -- `test/` — Test files - -See `app/backend/` for the primary backend implementation and conventions. - ---- - -# Backend Guide for shadcn/ui - -When integrating a frontend built with **shadcn/ui**, backend endpoints should provide consistent and predictable JSON responses to simplify component integration. - -## Success Response - -```json -{ - "success": true, - "data": {}, - "message": "Request completed successfully" -} -``` - -## Error Response - -```json -{ - "success": false, - "error": { - "code": "VALIDATION_ERROR", - "message": "Invalid request", - "fields": { - "email": "Email is required" - } - } -} -``` - -## Recommendations - -- Return consistent response structures. -- Use proper HTTP status codes. -- Include field-level validation errors. -- Support pagination for table components. -- Keep payloads predictable for frontend consumers. -- Avoid exposing internal implementation details. - -## Example Table Response - -```json -{ - "success": true, - "data": { - "items": [], - "pagination": { - "page": 1, - "limit": 10, - "total": 0 - } - } -} -``` - -## Example Select Response - -```json -{ - "success": true, - "data": [ - { - "label": "Admin", - "value": "admin" - }, - { - "label": "User", - "value": "user" - } - ] -} -``` -``` - ---- - -# AI Hints — How They Are Generated and Used - -The AI subsystem lives in `src/ai/` and exposes three capabilities to the rest of the platform: **chat-based mentoring**, **graduated task hints**, and **AI pre-scoring of code submissions**. This section focuses on hints specifically, then covers the supporting pieces. - -## Architecture Overview - -``` -Client (frontend / mobile) - │ - ▼ - POST /ai/hint ← AiController - │ - ▼ - AiService.getHint() ← business logic, hint store, difficulty routing - │ - ├── [hint found in store] → return stored hint + increment usedCount - └── [no hint in store] → return generic fallback message -``` - -When `AI_PROVIDER=claude` (or `openai`) is set, the `processChatRequest` path uses the provider to generate dynamic responses. The hint path currently uses a pre-seeded in-memory store — dynamic AI-generated hints are the planned Phase 2 upgrade (see below). - -## Request / Response Shape - -### POST `/ai/hint` - -**Request body** (`GetHintDto`): - -```json -{ - "challengeId": "sample-challenge-001", - "userId": "user-abc", - "difficulty": 2 -} -``` - -| Field | Type | Required | Description | -|---|---|---|---| -| `challengeId` | string | ✅ | Identifier of the task or challenge | -| `userId` | string | ✅ | Identifier of the requesting learner | -| `difficulty` | number | ❌ | Hint tier to request (1 = most gentle, 3 = most specific). Defaults to 1 | - -**Response body** (`AiHintResponse`): - -```json -{ - "hint": "Consider edge cases - empty, null, or out-of-range inputs.", - "hintId": "3f2c1a...", - "difficulty": 2 -} -``` - -| Field | Type | Description | -|---|---|---| -| `hint` | string | The hint text shown to the learner | -| `hintId` | string | UUID of the specific hint record (for analytics) | -| `difficulty` | number | Difficulty tier that was actually served (may differ from request if the requested tier was unavailable) | - -If no hints exist for the given `challengeId`, the API returns HTTP 200 with a generic fallback: - -```json -{ - "hint": "No hints available for this challenge yet. Keep trying!", - "hintId": "", - "difficulty": 1 -} -``` - -## Hint Difficulty Tiers - -Hints are designed to be **graduated** — each tier reveals progressively more information so learners are guided without being spoiled. - -| Tier | Intent | Example | -|---|---|---| -| **1** — Conceptual nudge | Reframe the problem; no implementation detail | `"Start by understanding the problem requirements thoroughly."` | -| **2** — Edge-case reminder | Point toward gotchas without giving code | `"Consider edge cases — empty, null, or out-of-range inputs."` | -| **3** — Algorithmic direction | Suggest an approach or pattern | `"Implement brute-force first, then optimize."` | - -When a learner requests tier 2 but only tier 1 is stored, `AiService.getHint()` falls back to the first available hint for that challenge rather than returning nothing. - -## How Hints Are Stored and Seeded - -`AiService` maintains an in-memory `Map` called `hints`. On startup, `initializeSampleHints()` pre-populates it with the three sample tiers for `"sample-challenge-001"`. - -``` -Hint { - id – UUID - challengeId – which challenge this hint belongs to - hint – hint text - difficulty – tier number (1–3) - usedCount – incremented each time the hint is served -} -``` - -`usedCount` is tracked so the analytics layer can identify which hints learners reach most often — a signal that difficulty calibration may need adjustment on a given challenge. - -In production, this in-memory store will be replaced by a database table. The service interface (`getHint`, `AiHintResponse`) will remain unchanged. - -## AI Provider Wiring - -The hint system currently runs entirely off the in-memory store, so it works without any API key configured. The full AI-powered chat path uses a pluggable provider selected at startup: - -``` -AI_PROVIDER=claude → ClaudeProvider (Anthropic Messages API) -AI_PROVIDER=openai → OpenaiProvider (OpenAI Chat Completions API) -(unset / other) → null provider (deterministic fallback responses) -``` - -The factory is defined in `AiModule` and injects the chosen provider into `AiService` via the `AI_PROVIDER` token: - -```typescript -// src/ai/ai.module.ts -const aiProviderFactory = { - provide: AI_PROVIDER, - useFactory: (configService: ConfigService) => { - const provider = configService.get('AI_PROVIDER'); - if (provider === 'openai') return new OpenaiProvider(configService); - if (provider === 'claude') return new ClaudeProvider(configService); - return null; // ← fallback, no external calls - }, - inject: [ConfigService], -}; -``` - -`ClaudeProvider` calls `POST https://api.anthropic.com/v1/messages` using the model specified by `AI_MODEL` (default: `claude-sonnet-4-20250514`), with `AI_MAX_TOKENS` (default: 4096) and `AI_TEMPERATURE` (default: 0.7). - -## Environment Variables - -| Variable | Default | Description | -|---|---|---| -| `AI_PROVIDER` | _(none)_ | `claude` or `openai`; omit to use offline fallback | -| `ANTHROPIC_API_KEY` | _(none)_ | Required when `AI_PROVIDER=claude` | -| `OPENAI_API_KEY` | _(none)_ | Required when `AI_PROVIDER=openai` | -| `AI_MODEL` | `claude-sonnet-4-20250514` | Model name passed to the provider | -| `AI_MAX_TOKENS` | `4096` | Maximum tokens per AI response | -| `AI_TEMPERATURE` | `0.7` | Sampling temperature (0 = deterministic, 1 = creative) | - -Copy `.env.example` and fill in the relevant keys: - -```bash -cp .env.example .env -``` - -## Related Endpoints - -| Method | Path | Description | -|---|---|---| -| `POST` | `/ai/hint` | Fetch a graduated hint for a challenge | -| `POST` | `/ai/chat` | Send a free-form message to the AI Mentor | -| `POST` | `/ai/pre-score` | Submit code for an AI pre-score before tutor review | -| `GET` | `/ai/history/:userId` | Retrieve a user's full chat history | - -### POST `/ai/chat` - -Sends a conversational message to the AI Mentor. The system prompt is fixed to `"You are a helpful Rust programming tutor."` The full message history per user is stored in memory and returned by `GET /ai/history/:userId`. - -Request body fields: `message` (string), `userId` (string), optional `context` (object). - -### POST `/ai/pre-score` - -Performs a static analysis pre-score on submitted Rust code before it enters the tutor review queue. The heuristic checks for: - -- Presence of `fn main()` (+15 pts) -- Use of functions with non-trivial line count (+15 pts) -- Presence of comments (+10 pts) -- Code length > 20 lines (+10 pts) - -Base score is 50. Final score is clamped to [0, 100]. A `confidence` of `0.7` is always reported in the current placeholder — this will be replaced by a model-calibrated confidence value once the full AI grading pipeline is wired in. - -## Planned Enhancements (Phase 2) - -- **Dynamic hint generation** — when no hint is stored for a `challengeId`, fall through to the AI provider to generate one on-demand using the task description as context. -- **Per-user hint gating** — track how many hints a learner has consumed per challenge and reduce XP payout accordingly. -- **Database persistence** — migrate `hints` and `chatHistory` maps to PostgreSQL via the Supabase client. -- **Streaming responses** — switch the chat endpoint to Server-Sent Events for real-time token streaming. diff --git a/BackendAcademy/Dockerfile b/BackendAcademy/Dockerfile deleted file mode 100644 index b46d601978..0000000000 --- a/BackendAcademy/Dockerfile +++ /dev/null @@ -1,38 +0,0 @@ -# ── Stage 1: deps ──────────────────────────────────────────────────────────── -FROM node:20-alpine AS deps -WORKDIR /app - -COPY package.json package-lock.json* ./ -RUN npm ci --omit=dev - -# ── Stage 2: build ──────────────────────────────────────────────────────────── -FROM node:20-alpine AS build -WORKDIR /app - -COPY package.json package-lock.json* ./ -RUN npm ci - -COPY . . -RUN npm run build - -# ── Stage 3: runtime ───────────────────────────────────────────────────────── -FROM node:20-alpine AS runtime -WORKDIR /app - -ENV NODE_ENV=production -ENV PORT=3000 - -# Non-root user for least-privilege execution -RUN addgroup -S appgroup && adduser -S appuser -G appgroup - -COPY --from=deps /app/node_modules ./node_modules -COPY --from=build /app/dist ./dist - -USER appuser - -EXPOSE 3000 - -HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ - CMD wget -qO- http://localhost:3000/health || exit 1 - -CMD ["node", "dist/main.js"] \ No newline at end of file diff --git a/BackendAcademy/README.md b/BackendAcademy/README.md index 701157ba70..8195d8bc6b 100644 --- a/BackendAcademy/README.md +++ b/BackendAcademy/README.md @@ -1,256 +1,30 @@ # BackendAcademy -RustAcademy backend module — NestJS backend implementation. +NestJS backend API for RustAcademy — the decentralized, AI-powered Rust programming academy on Stellar. -## Getting Started +## Modules (planned) -```bash -pnpm install -pnpm start:dev -``` - -The server starts on `http://localhost:3000` by default (configurable via `PORT`). - -## Environment Variables - -The following environment variables are validated at startup. Copy `.env.example` (or set them in your deployment config) and customize as needed. - -| Variable | Required (prod) | Default (dev/test) | Description | -| ---------------------- | ------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | -| `NODE_ENV` | No | `development` | Runtime environment (`development`, `production`, `test`) | -| `PORT` | No | `3000` | Server port | -| `CORS_ORIGIN` | No | `*` | Allowed CORS origin(s): `*` or a comma-separated list | -| `DATABASE_URL` | **Yes** | `postgresql://…/rustacademy_development` | Database connection string. Mandatory in production — the service refuses to boot without persistence configured | -| `REDIS_HOST` | **Yes** | `localhost` | Redis host for caching and background jobs. Mandatory in production | -| `REDIS_PORT` | No | `6379` | Redis port | -| `REDIS_PASSWORD` | No | — | Optional Redis password | -| `JWT_SECRET` | **Yes** | `development`-only insecure value | Secret key for JWT signing. Required in production and must be ≥ 32 chars; the shipped example/development values are rejected | -| `ASSET_SIGNING_SECRET` | **Yes** | `development`-only insecure value | HMAC secret for signed asset URLs. Required in production; an empty secret makes signed URLs forgeable | -| `AI_PROVIDER` | No | `mock` | AI provider (`claude`, `openai`, `mock`) | -| `ANTHROPIC_API_KEY` | When `AI_PROVIDER=claude` | — | Anthropic API key | -| `OPENAI_API_KEY` | When `AI_PROVIDER=openai` | — | OpenAI API key | -| `AI_MODEL` | No | — | AI model override | -| `AI_MAX_TOKENS` | No | `4096` | Max tokens for AI requests | -| `AI_TEMPERATURE` | No | `0.7` | Temperature for AI responses | -| `LOCALE` | No | `en` | Localization locale (currently `en` supported) | -| `ASSETS_UPLOAD_DIR` | No | `./data/uploads` | Directory where uploaded assets are persisted | -| `ASSETS_STATIC_DIR` | No | `./public` | Read-only static asset directory served at `/static` | -| `ASSETS_BASE_URL` | No | `/api/v1/assets` | Base URL advertised inside asset metadata | -| `ASSETS_MAX_SIZE_MB` | No | `10` | Maximum size of a single uploaded asset (MB) | -| `ASSETS_MAX_TOTAL_MB` | No | `1024` | Aggregate byte quota across all stored assets (MB) | -| `ASSETS_MAX_COUNT` | No | `10000` | Maximum number of assets retained by the registry | - -> **Production note:** when `NODE_ENV=production`, startup validation rejects missing -> `DATABASE_URL`, `REDIS_HOST`, `JWT_SECRET` and `ASSET_SIGNING_SECRET`, low-entropy -> secrets, and placeholder values copied from `.env.example`. Development and test -> environments keep explicit (non-production) defaults so local boots stay simple. -> Validation errors never include secret values. - -## Cron Scheduling - -Background jobs are configured via cron expressions in the standard 5-field format: - -``` -minute hour day-of-month month day-of-week -``` - -### Schedule Configuration - -| Variable | Default | Description | -| ----------------------------- | -------------- | ------------------------------ | -| `CRON_CLEANUP_SCHEDULE` | `0 0 * * *` | Daily cleanup at midnight | -| `CRON_ANALYTICS_SCHEDULE` | `0 */6 * * *` | Analytics every 6 hours | -| `CRON_NOTIFICATIONS_SCHEDULE` | `*/30 * * * *` | Notifications every 30 minutes | - -### Cron Expression Reference - -| Expression | Meaning | -| ------------- | ------------------------------------- | -| `* * * * *` | Every minute | -| `*/5 * * * *` | Every 5 minutes | -| `0 * * * *` | Every hour at minute 0 | -| `0 0 * * *` | At midnight daily | -| `0 0 * * 0` | At midnight every Sunday | -| `0 9 * * 1-5` | At 9 AM, Monday–Friday | -| `0 0 1 * *` | At midnight on the 1st of every month | - -### Field Ranges - -| Field | Allowed Values | -| ------------ | ------------------------ | -| Minute | `0–59` | -| Hour | `0–23` | -| Day of Month | `1–31` | -| Month | `1–12` | -| Day of Week | `0–7` (0 or 7 is Sunday) | - -Invalid cron expressions are caught at startup and logged as errors. Use the `/api/jobs/schedules` endpoint to view all configured schedules and their next run times. - -## Localization - -The backend ships with a localization service (`I18nModule`) that provides translated strings for admin and learner flows. - -- **Default locale:** `en` -- **Configurable via:** `LOCALE` environment variable -- **Available locales:** `en` - -The `LocalizationService` is global and injectable. Use `l10n.t('key')` to retrieve localized strings in any service/controller. - -### Adding a new locale - -Add a new entry to the `STRINGS` map in `src/i18n/localization.service.ts` with all required keys. - -## Structure - -- `src/` — Application source code (NestJS modules, controllers, services) -- `src/config/` — Configuration and environment validation -- `src/database/` — Database module, migration service with preflight validation and rollback -- `src/i18n/` — Localization module -- `src/jobs/` — Background job scheduling with cron validation and notification batching -- `src/monitoring/` — Metrics and observability -- `src/notifications/` — Notification delivery with provider abstraction (email, push, in-app) and batching -- `test/` — Test files - -## Validation - -- Environment variables are validated at startup using `src/config/env.schema.ts`. -- Localization fallback and translation key validation are covered by unit tests and CI via `pnpm test`. - -See `app/backend/` for the primary backend implementation and conventions. - -## Database Migrations (#398) +- `auth` — JWT auth for learners and tutors +- `users` — Learner/tutor profiles +- `courses` — Learning academy courses and lessons +- `tasks` — Coding tasks and submissions +- `rewards` — XLM learn-to-earn rewards +- `ai-mentor` — Claude-powered AI mentor +- `social` — Community feed +- `chat` — Real-time messaging +- `stellar` — Stellar/Soroban integration +- `supabase` — Database client integration -The `MigrationService` provides preflight validation and rollback awareness for database migrations: +## Setup -### Migration Scripts - -| Script | Description | -| -------------------------- | ----------------------------------------------- | -| `pnpm migration:preflight` | Run preflight checks before applying migrations | -| `pnpm migration:rollback` | Roll back the most recent migration | -| `pnpm migration:history` | View migration history | -| `pnpm migration:dry-run` | Simulate a rollback without executing | - -### Preflight Validation - -The migration service performs these checks before applying changes: - -1. **Database Connectivity** — Verifies the database is reachable -2. **Schema State** — Inspects current schema for compatibility -3. **Pending Migrations** — Lists migrations waiting to be applied -4. **Environment Configuration** — Validates environment variables -5. **Destructive Migration Risk** — Detects potentially destructive operations - -### Rollback Plan - -Each preflight check generates a rollback plan describing the steps needed to revert the most recent migrations. Destructive migrations (DROP, TRUNCATE) are flagged as non-reversible. - -## Notification Providers (#388) - -Notifications are delivered through a centralized provider interface (`INotificationProvider`), supporting multiple channels: - -| Provider | ID | Description | -| -------- | -------- | ----------------------------------------------------------- | -| Email | `email` | Sends notifications via email with template personalization | -| Push | `push` | Delivers push notifications to user devices | -| In-App | `in-app` | Stores notifications in the user's in-app feed | - -Configure enabled providers via `NOTIFICATION_PROVIDERS` (comma-separated: `email,push,in-app`). - -## Email Template Fallbacks (#387) - -Email templates use `{{placeholder}}` syntax for personalization. When user data is incomplete, -missing fields are replaced with sensible defaults so content never renders blank: - -| Placeholder | Fallback | -| --------------------- | ------------------------------- | -| `{{name}}` | "RustAcademy Learner" | -| `{{courseName}}` | "your course" | -| `{{milestoneName}}` | "a new milestone" | -| `{{submissionTitle}}` | "your submission" | -| Any unrecognized key | `[key]` (safe bracket notation) | - -## Notification Batching (#386) - -Low-priority reminders (streak nudges, course suggestions) can be grouped into batches -to reduce noise and improve delivery efficiency: - -| Variable | Default | Description | -| ------------------------------ | ------- | --------------------------------- | -| `NOTIFICATION_BATCH_ENABLED` | `false` | Enable batching | -| `NOTIFICATION_BATCH_MAX_SIZE` | `10` | Max notifications per batch | -| `NOTIFICATION_BATCH_WINDOW_MS` | `30000` | Auto-flush window in milliseconds | - ---- - -# Backend Guide for shadcn/ui - -When integrating a frontend built with **shadcn/ui**, backend endpoints should provide consistent and predictable JSON responses to simplify component integration. - -## Success Response - -```json -{ - "success": true, - "data": {}, - "message": "Request completed successfully" -} -``` - -## Error Response - -```json -{ - "success": false, - "error": { - "code": "VALIDATION_ERROR", - "message": "Invalid request", - "fields": { - "email": "Email is required" - } - } -} -``` - -## Recommendations - -- Return consistent response structures. -- Use proper HTTP status codes. -- Include field-level validation errors. -- Support pagination for table components. -- Keep payloads predictable for frontend consumers. -- Avoid exposing internal implementation details. - -## Example Table Response - -```json -{ - "success": true, - "data": { - "items": [], - "pagination": { - "page": 1, - "limit": 10, - "total": 0 - } - } -} +```bash +pnpm install +pnpm --filter @rustacademy/backend-academy dev ``` -## Example Select Response +## Scripts -```json -{ - "success": true, - "data": [ - { - "label": "Admin", - "value": "admin" - }, - { - "label": "User", - "value": "user" - } - ] -} -``` +- `pnpm dev` — start in watch mode +- `pnpm build` — production build +- `pnpm test` — run unit tests +- `pnpm test:e2e` — run e2e tests diff --git a/BackendAcademy/Tenant.md b/BackendAcademy/Tenant.md deleted file mode 100644 index ba71961a6a..0000000000 --- a/BackendAcademy/Tenant.md +++ /dev/null @@ -1,33 +0,0 @@ -TENANT - -## Step 1: Add persistence model -- Create `BackendAcademy/src/sessions/attendance.entity.ts` using TypeORM. -- Decide primary key and fields: `sessionKey`, `userId`, `joinedAt`, `leftAt`, `durationSeconds`. - -## Step 2: Implement attendance service -- Create `BackendAcademy/src/sessions/attendance.service.ts`. -- Add methods: `join`, `leave`, `getSessionStats`. -- Join is idempotent per `(sessionKey,userId)` while active. - -## Step 3: Implement controller + DTOs -- Create `BackendAcademy/src/sessions/dto/join-session-attendance.dto.ts`. -- Create `BackendAcademy/src/sessions/dto/leave-session-attendance.dto.ts`. -- Create `BackendAcademy/src/sessions/dto/get-session-attendance-stats.dto.ts` (if needed). -- Create `BackendAcademy/src/sessions/attendance.controller.ts` with endpoints: - - `POST /api/v1/sessions/attendance/join` - - `POST /api/v1/sessions/attendance/leave` - - `GET /api/v1/sessions/attendance/:sessionKey/stats` - -## Step 4: Add NestJS module wiring -- Create `BackendAcademy/src/sessions/sessions.module.ts` and register TypeORM entity + providers. - -## Step 5: Wire into app -- Update `BackendAcademy/src/app.module.ts` to import `SessionsModule`. - -## Step 6: Verify build -- Run `npm run build` (or `npm test`) inside `BackendAcademy`. - -## Progress -- Step 1-5 implemented (sessions module + attendance tracking). - - diff --git a/BackendAcademy/Ticket.md b/BackendAcademy/Ticket.md deleted file mode 100644 index d1a02c037e..0000000000 --- a/BackendAcademy/Ticket.md +++ /dev/null @@ -1,269 +0,0 @@ -Ticket - -RustAcademy backend module — placeholder for future NestJS backend implementation. - -## Getting Started - -```bash -pnpm install -pnpm run dev -``` - -## Structure - -- `src/` — Application source code (NestJS modules, controllers, services) -- `test/` — Test files - -See `app/backend/` for the primary backend implementation and conventions. - ---- - -# Backend Guide for shadcn/ui - -When integrating a frontend built with **shadcn/ui**, backend endpoints should provide consistent and predictable JSON responses to simplify component integration. - -## Success Response - -```json -{ - "success": true, - "data": {}, - "message": "Request completed successfully" -} -``` - -## Error Response - -```json -{ - "success": false, - "error": { - "code": "VALIDATION_ERROR", - "message": "Invalid request", - "fields": { - "email": "Email is required" - } - } -} -``` - -## Recommendations - -- Return consistent response structures. -- Use proper HTTP status codes. -- Include field-level validation errors. -- Support pagination for table components. -- Keep payloads predictable for frontend consumers. -- Avoid exposing internal implementation details. - -## Example Table Response - -```json -{ - "success": true, - "data": { - "items": [], - "pagination": { - "page": 1, - "limit": 10, - "total": 0 - } - } -} -``` - -## Example Select Response - -```json -{ - "success": true, - "data": [ - { - "label": "Admin", - "value": "admin" - }, - { - "label": "User", - "value": "user" - } - ] -} -``` -``` - ---- - -# AI Hints — How They Are Generated and Used - -The AI subsystem lives in `src/ai/` and exposes three capabilities to the rest of the platform: **chat-based mentoring**, **graduated task hints**, and **AI pre-scoring of code submissions**. This section focuses on hints specifically, then covers the supporting pieces. - -## Architecture Overview - -``` -Client (frontend / mobile) - │ - ▼ - POST /ai/hint ← AiController - │ - ▼ - AiService.getHint() ← business logic, hint store, difficulty routing - │ - ├── [hint found in store] → return stored hint + increment usedCount - └── [no hint in store] → return generic fallback message -``` - -When `AI_PROVIDER=claude` (or `openai`) is set, the `processChatRequest` path uses the provider to generate dynamic responses. The hint path currently uses a pre-seeded in-memory store — dynamic AI-generated hints are the planned Phase 2 upgrade (see below). - -## Request / Response Shape - -### POST `/ai/hint` - -**Request body** (`GetHintDto`): - -```json -{ - "challengeId": "sample-challenge-001", - "userId": "user-abc", - "difficulty": 2 -} -``` - -| Field | Type | Required | Description | -|---|---|---|---| -| `challengeId` | string | ✅ | Identifier of the task or challenge | -| `userId` | string | ✅ | Identifier of the requesting learner | -| `difficulty` | number | ❌ | Hint tier to request (1 = most gentle, 3 = most specific). Defaults to 1 | - -**Response body** (`AiHintResponse`): - -```json -{ - "hint": "Consider edge cases - empty, null, or out-of-range inputs.", - "hintId": "3f2c1a...", - "difficulty": 2 -} -``` - -| Field | Type | Description | -|---|---|---| -| `hint` | string | The hint text shown to the learner | -| `hintId` | string | UUID of the specific hint record (for analytics) | -| `difficulty` | number | Difficulty tier that was actually served (may differ from request if the requested tier was unavailable) | - -If no hints exist for the given `challengeId`, the API returns HTTP 200 with a generic fallback: - -```json -{ - "hint": "No hints available for this challenge yet. Keep trying!", - "hintId": "", - "difficulty": 1 -} -``` - -## Hint Difficulty Tiers - -Hints are designed to be **graduated** — each tier reveals progressively more information so learners are guided without being spoiled. - -| Tier | Intent | Example | -|---|---|---| -| **1** — Conceptual nudge | Reframe the problem; no implementation detail | `"Start by understanding the problem requirements thoroughly."` | -| **2** — Edge-case reminder | Point toward gotchas without giving code | `"Consider edge cases — empty, null, or out-of-range inputs."` | -| **3** — Algorithmic direction | Suggest an approach or pattern | `"Implement brute-force first, then optimize."` | - -When a learner requests tier 2 but only tier 1 is stored, `AiService.getHint()` falls back to the first available hint for that challenge rather than returning nothing. - -## How Hints Are Stored and Seeded - -`AiService` maintains an in-memory `Map` called `hints`. On startup, `initializeSampleHints()` pre-populates it with the three sample tiers for `"sample-challenge-001"`. - -``` -Hint { - id – UUID - challengeId – which challenge this hint belongs to - hint – hint text - difficulty – tier number (1–3) - usedCount – incremented each time the hint is served -} -``` - -`usedCount` is tracked so the analytics layer can identify which hints learners reach most often — a signal that difficulty calibration may need adjustment on a given challenge. - -In production, this in-memory store will be replaced by a database table. The service interface (`getHint`, `AiHintResponse`) will remain unchanged. - -## AI Provider Wiring - -The hint system currently runs entirely off the in-memory store, so it works without any API key configured. The full AI-powered chat path uses a pluggable provider selected at startup: - -``` -AI_PROVIDER=claude → ClaudeProvider (Anthropic Messages API) -AI_PROVIDER=openai → OpenaiProvider (OpenAI Chat Completions API) -(unset / other) → null provider (deterministic fallback responses) -``` - -The factory is defined in `AiModule` and injects the chosen provider into `AiService` via the `AI_PROVIDER` token: - -```typescript -// src/ai/ai.module.ts -const aiProviderFactory = { - provide: AI_PROVIDER, - useFactory: (configService: ConfigService) => { - const provider = configService.get('AI_PROVIDER'); - if (provider === 'openai') return new OpenaiProvider(configService); - if (provider === 'claude') return new ClaudeProvider(configService); - return null; // ← fallback, no external calls - }, - inject: [ConfigService], -}; -``` - -`ClaudeProvider` calls `POST https://api.anthropic.com/v1/messages` using the model specified by `AI_MODEL` (default: `claude-sonnet-4-20250514`), with `AI_MAX_TOKENS` (default: 4096) and `AI_TEMPERATURE` (default: 0.7). - -## Environment Variables - -| Variable | Default | Description | -|---|---|---| -| `AI_PROVIDER` | _(none)_ | `claude` or `openai`; omit to use offline fallback | -| `ANTHROPIC_API_KEY` | _(none)_ | Required when `AI_PROVIDER=claude` | -| `OPENAI_API_KEY` | _(none)_ | Required when `AI_PROVIDER=openai` | -| `AI_MODEL` | `claude-sonnet-4-20250514` | Model name passed to the provider | -| `AI_MAX_TOKENS` | `4096` | Maximum tokens per AI response | -| `AI_TEMPERATURE` | `0.7` | Sampling temperature (0 = deterministic, 1 = creative) | - -Copy `.env.example` and fill in the relevant keys: - -```bash -cp .env.example .env -``` - -## Related Endpoints - -| Method | Path | Description | -|---|---|---| -| `POST` | `/ai/hint` | Fetch a graduated hint for a challenge | -| `POST` | `/ai/chat` | Send a free-form message to the AI Mentor | -| `POST` | `/ai/pre-score` | Submit code for an AI pre-score before tutor review | -| `GET` | `/ai/history/:userId` | Retrieve a user's full chat history | - -### POST `/ai/chat` - -Sends a conversational message to the AI Mentor. The system prompt is fixed to `"You are a helpful Rust programming tutor."` The full message history per user is stored in memory and returned by `GET /ai/history/:userId`. - -Request body fields: `message` (string), `userId` (string), optional `context` (object). - -### POST `/ai/pre-score` - -Performs a static analysis pre-score on submitted Rust code before it enters the tutor review queue. The heuristic checks for: - -- Presence of `fn main()` (+15 pts) -- Use of functions with non-trivial line count (+15 pts) -- Presence of comments (+10 pts) -- Code length > 20 lines (+10 pts) - -Base score is 50. Final score is clamped to [0, 100]. A `confidence` of `0.7` is always reported in the current placeholder — this will be replaced by a model-calibrated confidence value once the full AI grading pipeline is wired in. - -## Planned Enhancements (Phase 2) - -- **Dynamic hint generation** — when no hint is stored for a `challengeId`, fall through to the AI provider to generate one on-demand using the task description as context. -- **Per-user hint gating** — track how many hints a learner has consumed per challenge and reduce XP payout accordingly. -- **Database persistence** — migrate `hints` and `chatHistory` maps to PostgreSQL via the Supabase client. -- **Streaming responses** — switch the chat endpoint to Server-Sent Events for real-time token streaming. diff --git a/BackendAcademy/app.module.ts b/BackendAcademy/app.module.ts deleted file mode 100644 index 068e9ee093..0000000000 --- a/BackendAcademy/app.module.ts +++ /dev/null @@ -1 +0,0 @@ -export { AppModule } from './src/app.module'; diff --git a/BackendAcademy/docs/OPERATIONS.md b/BackendAcademy/docs/OPERATIONS.md deleted file mode 100644 index 9ce8e84600..0000000000 --- a/BackendAcademy/docs/OPERATIONS.md +++ /dev/null @@ -1,614 +0,0 @@ -# BackendAcademy — Operational Documentation & Runbooks - -> **BA-125** — This document fulfils the operational documentation requirement. -> It covers setup, module wiring, required services, environment variables, -> deployment, rollback, data recovery, and incident-response procedures. - ---- - -## Table of Contents - -1. [Service Overview](#1-service-overview) -2. [Required Infrastructure](#2-required-infrastructure) -3. [Environment Variables](#3-environment-variables) -4. [Module Wiring](#4-module-wiring) -5. [Local Development Setup](#5-local-development-setup) -6. [Deployment Guide](#6-deployment-guide) -7. [Running Database Migrations](#7-running-database-migrations) -8. [CI/CD Gates](#8-cicd-gates) -9. [Rollback Runbook](#9-rollback-runbook) -10. [Data Recovery Runbook](#10-data-recovery-runbook) -11. [Incident Response Runbook](#11-incident-response-runbook) -12. [Troubleshooting Guide](#12-troubleshooting-guide) -13. [Health Checks & Monitoring](#13-health-checks--monitoring) -14. [Secrets Management](#14-secrets-management) - ---- - -## 1. Service Overview - -**BackendAcademy** is the NestJS API server powering RustAcademy. -It provides: - -- REST API for courses, lessons, tasks, submissions, users, and rewards -- AI Mentor integration (Claude / OpenAI / mock) -- Notification delivery (email, push, in-app) with circuit-breaker resilience -- Social feed with ownership and moderation enforcement -- Background job queue for grading and certificate minting -- Stellar / Soroban smart-contract integration for on-chain rewards - -**Default port:** `3000` (configurable via `PORT`). -**Health endpoint:** `GET /health` - ---- - -## 2. Required Infrastructure - -| Service | Purpose | Required in prod? | -|---------------|--------------------------------------------|:-----------------:| -| PostgreSQL 15+ | Primary datastore for all application data | **Yes** | -| Redis 7+ | Pub/sub, job queue, rate-limiting cache | **Yes** | -| Anthropic Claude API (optional) | AI Mentor chat and grading | No (mock fallback) | -| OpenAI API (optional) | AI Mentor alternative | No (mock fallback) | -| Stellar Horizon | Blockchain account/payment lookups | No (stubbed) | -| SMTP / SendGrid / SES | Email delivery | No (logged only in dev) | -| FCM / APNs | Push notifications | No (logged only in dev) | - ---- - -## 3. Environment Variables - -Copy `.env.example` as a starting point: - -```bash -cp .env.example .env -``` - -### 3.1 Required in Production - -| Variable | Description | Constraints | -|-----------------------|---------------------------------------------------|----------------------------------------------| -| `DATABASE_URL` | PostgreSQL connection string | Full URL including user/password/db name | -| `REDIS_HOST` | Redis hostname or IP | Must be reachable from the app container | -| `JWT_SECRET` | JWT signing secret | ≥ 32 chars; must not be a placeholder value | -| `ASSET_SIGNING_SECRET`| HMAC secret for signed asset download URLs | Must not be empty; empty = forgeable URLs | - -### 3.2 Optional / Provider-specific - -| Variable | Default | Description | -|------------------------|--------------|---------------------------------------------------| -| `PORT` | `3000` | HTTP port the server listens on | -| `NODE_ENV` | `development`| Runtime mode: `development`, `production`, `test` | -| `CORS_ORIGIN` | `*` | Allowed CORS origins (comma-separated or `*`) | -| `REDIS_PORT` | `6379` | Redis port | -| `REDIS_PASSWORD` | — | Redis auth password (if required) | -| `JWT_CLOCK_SKEW_SECONDS` | `30` | Allowed JWT clock drift in seconds (0–120) | -| `API_KEY_SECRET` | — | Shared secret for API-key authentication | -| `AI_PROVIDER` | `mock` | `claude` | `openai` | `mock` | -| `ANTHROPIC_API_KEY` | — | Required when `AI_PROVIDER=claude` | -| `OPENAI_API_KEY` | — | Required when `AI_PROVIDER=openai` | -| `AI_MODEL` | — | Override the default AI model | -| `AI_MAX_TOKENS` | `4096` | Token budget per AI response | -| `AI_TEMPERATURE` | `0.7` | Sampling temperature for AI responses | -| `AI_RETRY_MAX_ATTEMPTS`| `3` | AI provider retry attempts (429/5xx) | -| `AI_RETRY_BASE_DELAY_MS`| `250` | Base back-off delay in milliseconds | -| `AI_RETRY_MAX_DELAY_MS`| `5000` | Maximum back-off delay in milliseconds | -| `ASSETS_UPLOAD_DIR` | `./data/uploads` | Where uploaded assets are stored on disk | -| `ASSETS_STATIC_DIR` | `./public` | Read-only static asset directory | -| `ASSETS_BASE_URL` | `/api/v1/assets` | Base URL in asset metadata responses | -| `ASSETS_MAX_SIZE_MB` | `10` | Per-file upload size limit (MB) | -| `ASSETS_MAX_TOTAL_MB` | `1024` | Aggregate asset storage quota (MB) | -| `ASSETS_MAX_COUNT` | `10000` | Maximum number of stored assets | -| `LOCALE` | `en` | Localization locale | -| `CRON_CLEANUP_SCHEDULE`| `0 0 * * *` | Daily cleanup cron (midnight UTC) | -| `CRON_ANALYTICS_SCHEDULE`| `0 */6 * * *` | Analytics aggregation every 6 hours | -| `CRON_NOTIFICATIONS_SCHEDULE`| `*/30 * * * *` | Notification batch flush every 30 min | - -> **Production startup guardrails:** When `NODE_ENV=production`, the app -> refuses to boot if `DATABASE_URL`, `REDIS_HOST`, `JWT_SECRET`, or -> `ASSET_SIGNING_SECRET` are missing, empty, or set to placeholder values -> from `.env.example`. This prevents silent misconfiguration in production. - ---- - -## 4. Module Wiring - -The application entry-point is `src/main.ts`. All NestJS modules are registered in `src/app.module.ts`. - -| Module | Key providers | Notes | -|-----------------------|----------------------------------------------------|------------------------------------------| -| `ConfigModule` | `ConfigService`, env validation via `Joi` | Global; validates all env vars at startup | -| `DatabaseModule` | `DatabaseService`, `MigrationService` | Wraps `@supabase/supabase-js` client | -| `AuthModule` | `AuthSessionService`, JWT guards, decorators | JWT + API-key auth | -| `UsersModule` | `UsersService`, `UserProfileService` | | -| `CoursesModule` | `CourseService`, `CertificateService`, `CourseRatingService` | Progress tracking | -| `LessonsModule` | `LessonService` | | -| `TasksModule` | `TaskService`, `TaskOrchestratorService` | | -| `SubmissionsModule` | `SubmissionService`, `TutorReviewService` | Grading pipeline | -| `JobsModule` | `JobsService`, `GradingJobService` | In-memory job queue | -| `DeadLetterQueueModule` | `DlqService`, `GradingRetryPolicy` | | -| `AiModule` | `AiService`, `PromptTemplateService`, `ClaudeProvider` | Pluggable AI backend | -| `NotificationsModule` | `NotificationsService`, `EmailNotificationProvider`, `PushNotificationProvider`, `InAppNotificationProvider` | Resilient delivery | -| `SocialModule` | `SocialService` | Post ownership + moderation enforcement | -| `ChatModule` | `ChatService`, rate limiting | | -| `RewardsModule` | `RewardsService`, `StreakService`, `ReferralService` | XLM reward logic | -| `WalletModule` | `WalletService` | Stellar wallet ops | -| `ContractsModule` | `ContractsService`, `ContractRegistryService` | Soroban contract calls | -| `BadgesModule` | `BadgesService` | NFT badge minting | -| `SecurityModule` | `SecurityService`, `AntiCheatService` | | -| `AdminModule` | `AdminService` | Admin-only operations | -| `MonitoringModule` | `MetricsService`, `MonitoringService` | Prometheus metrics | -| `RedisModule` | `RedisService` | Redis client wrapper | -| `HealthModule` | `HealthController` | `GET /health` | -| `LoggingModule` | `CorrelationLoggerService`, `ErrorTrackingService` | | - ---- - -## 5. Local Development Setup - -### Prerequisites - -- Node.js 20+ -- npm 9+ (or pnpm 9+) -- Docker + Docker Compose (for PostgreSQL and Redis) - -### Steps - -```bash -# 1. Install dependencies -cd BackendAcademy -npm install - -# 2. Copy and fill in environment variables -cp .env.example .env -# Edit .env — set DATABASE_URL, REDIS_HOST, JWT_SECRET, etc. - -# 3. Start PostgreSQL and Redis via Docker -docker-compose up -d # from the repo root - -# 4. Run database migrations -npx ts-node src/database/migration-cli.ts migrate - -# 5. Start the development server (hot reload) -npm run start:dev -# → http://localhost:3000 - -# 6. Verify the server is healthy -curl http://localhost:3000/health -``` - -### Running tests locally - -```bash -# Unit tests only (fast, no external dependencies) -npm run test:unit - -# All tests -npm test - -# TypeScript check -npm run typecheck - -# Lint -npm run lint -``` - ---- - -## 6. Deployment Guide - -### 6.1 Docker (recommended) - -```bash -# Build the image -docker build -t rustacademy-backend:latest ./BackendAcademy - -# Run in production -docker run -d \ - --name rustacademy-backend \ - -p 3000:3000 \ - --env-file /path/to/prod.env \ - rustacademy-backend:latest -``` - -> The Dockerfile uses a multi-stage build with a non-root `appuser` for least-privilege execution. - -### 6.2 Railway / Render - -1. Point the service root to `BackendAcademy/`. -2. Set `BUILD_COMMAND` to `npm run build` and `START_COMMAND` to `node dist/main.js`. -3. Add all required environment variables from [Section 3](#3-environment-variables) in the platform dashboard. -4. Enable the health check endpoint at `/health`. - -### 6.3 Pre-deployment checklist - -- [ ] All required env vars are set (see [Section 3.1](#31-required-in-production)) -- [ ] `JWT_SECRET` and `ASSET_SIGNING_SECRET` are ≥ 32 chars and not placeholder values -- [ ] Database migrations have been reviewed and staged (`migration-cli.ts dry-run`) -- [ ] Feature flags for risky changes are set to `false` initially -- [ ] Rollback plan confirmed (see [Section 9](#9-rollback-runbook)) -- [ ] Team notified of deployment window -- [ ] Monitoring dashboards open and baselining - ---- - -## 7. Running Database Migrations - -BackendAcademy uses a custom migration service (`src/database/migration.service.ts`). - -```bash -# Inside BackendAcademy directory (or Docker exec into the container) - -# Run all pending migrations (auto-detects by file timestamp) -npx ts-node src/database/migration-cli.ts migrate - -# List applied migrations -npx ts-node src/database/migration-cli.ts status - -# Rollback the last batch of migrations -npx ts-node src/database/migration-cli.ts rollback - -# Rollback a specific number of steps -npx ts-node src/database/migration-cli.ts rollback --steps 2 -``` - -> **Always take a database snapshot before running migrations in production.** -> See [Section 10](#10-data-recovery-runbook) for recovery procedures. - ---- - -## 8. CI/CD Gates - -All CI jobs are defined in `.github/workflows/backend-academy.yml`. -The following gates **must all pass** before a PR can be merged: - -| Job | Command | What it checks | -|------------------------|----------------------------|------------------------------------------| -| `typecheck` | `npm run typecheck` | TypeScript compiler errors (no emit) | -| `lint` | `npm run lint` | ESLint rules on `src/**/*.ts` | -| `unit-tests` | `npm run test:unit` | All `*.spec.ts` unit tests | -| `integration-tests` | Jest on learner-journey | Integration + AI provider suites | -| `build` | `npm run build` | NestJS production build (requires typecheck+lint) | - -See the workflow file for actionable failure guidance per job. - ---- - -## 9. Rollback Runbook - -### Trigger - -Deploy produced errors (5xx spike, health check failures, data corruption). - -### Procedure - -**Step 1 — Assess the blast radius** - -```bash -# Check recent error rate from monitoring -curl http://:3000/metrics | grep http_requests_total -# Or check health -curl http://:3000/health -``` - -**Step 2 — Roll back the application** - -*Docker:* -```bash -# Pull and run the previous image tag -docker stop rustacademy-backend -docker run -d \ - --name rustacademy-backend \ - -p 3000:3000 \ - --env-file /path/to/prod.env \ - rustacademy-backend: -``` - -*Railway/Render:* Use the platform's **Rollback** or **Redeploy Previous** button in the dashboard. - -*Git-based:* -```bash -# In CI: re-trigger the pipeline on the previous good commit -git revert HEAD --no-edit -git push origin main -``` - -**Step 3 — Roll back database migrations (if needed)** - -Only run if the new migration caused the issue: - -```bash -# Inside the old container or via db migration CLI -npx ts-node src/database/migration-cli.ts rollback -``` - -> ⚠️ Only rollback if the migration is reversible. Destructive migrations -> (column drops, data transformations) require manual data recovery. -> See [Section 10](#10-data-recovery-runbook). - -**Step 4 — Verify recovery** - -```bash -curl http://:3000/health -# Expect: { "status": "ok", ... } -``` - -**Step 5 — Post-mortem** - -After recovery, document in a GitHub issue: -- What failed and when -- Root cause -- Fix and prevention steps - ---- - -## 10. Data Recovery Runbook - -### 10.1 Before any migration in production - -```bash -# Supabase — create a manual backup via the dashboard -# Or via psql: -pg_dump $DATABASE_URL --format=custom --file=rustacademy_$(date +%Y%m%d_%H%M%S).dump -``` - -### 10.2 Restore from backup - -```bash -# Stop the application to prevent writes during restore -docker stop rustacademy-backend - -# Restore -pg_restore --clean --dbname $DATABASE_URL rustacademy_.dump - -# Restart -docker start rustacademy-backend -``` - -### 10.3 Recovering deleted records - -The application does not currently implement soft-deletes globally. -If records are accidentally deleted: - -1. Stop the application immediately to prevent further writes. -2. Restore from the most recent pre-deletion backup. -3. Use a binary diff (WAL-based) recovery if the PostgreSQL instance has - point-in-time recovery (PITR) enabled via Supabase. - -### 10.4 Redis data loss - -Redis is used for ephemeral state (sessions, rate-limit counters, job queues). -Its data is not durably persisted by default. - -- **Sessions:** Users will be logged out and need to re-authenticate. -- **Rate-limit counters:** Will reset; clients may temporarily exceed limits. -- **Job queue:** Any in-flight jobs will be lost. Trigger a manual requeue if needed. - -**Prevention:** Enable Redis AOF persistence or use a managed Redis with -automatic replication (e.g., Upstash, Redis Cloud). - ---- - -## 11. Incident Response Runbook - -### 11.1 Severity levels - -| Level | Description | Response SLA | -|-------|------------------------------------------|-------------| -| P1 | Full service outage / payment failures | < 15 min | -| P2 | Partial outage / degraded AI/rewards | < 1 hour | -| P3 | Single feature broken, workaround exists | < 4 hours | -| P4 | Minor issue / cosmetic bug | Next sprint | - -### 11.2 Common incidents - -#### Service fails to start - -**Symptoms:** Container exits immediately; health check never responds. - -**Diagnosis:** -```bash -docker logs rustacademy-backend 2>&1 | head -50 -``` - -**Common causes:** -- Missing required env var — look for `ConfigValidationError` in logs. -- Database unreachable — look for `ECONNREFUSED` or `ETIMEDOUT` on `DATABASE_URL`. -- Port already in use — `EADDRINUSE :3000`. - -**Fix:** -- Add the missing env var to the deployment config. -- Ensure the database and Redis containers are running and reachable. -- Free the port or change `PORT`. - ---- - -#### High error rate (5xx spike) - -**Symptoms:** Monitoring shows elevated `http_requests_total{status="5xx"}`. - -**Diagnosis:** -```bash -# Application logs -docker logs rustacademy-backend 2>&1 | grep ERROR | tail -30 - -# Health breakdown -curl http://:3000/health | jq -``` - -**Common causes:** -- Database connection pool exhausted → check `DATABASE_URL` connectivity. -- Redis connection lost → check `REDIS_HOST` / `REDIS_PORT`. -- Uncaught exception in a NestJS handler. - -**Fix:** -- Restart the service to reset connection pool. -- Check infrastructure for outages. -- If the 5xx is from a specific endpoint, use feature flags or deploy a patch. - ---- - -#### Notification provider circuit open - -**Symptoms:** Notifications are silently failing; circuit metrics show `OPEN`. - -**Diagnosis:** -```bash -curl http://:3000/health | jq '.providers' -# Look for: { "providerId": "email", "healthy": false } -``` - -**Fix:** -- Check the external provider (SendGrid, FCM, etc.) status page. -- The circuit will self-recover after 30 seconds of recovery timeout. -- To force reset without a redeploy, restart the container (circuit resets on startup). - ---- - -#### AI provider errors (429 / 5xx) - -**Symptoms:** AI Mentor responses fail; learners see error messages. - -**Diagnosis:** -```bash -docker logs rustacademy-backend 2>&1 | grep "AI" | tail -20 -``` - -**Fix:** -- Check `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` are valid. -- Check the AI provider's status page for outages. -- Set `AI_PROVIDER=mock` temporarily to fall back to offline responses. -- Adjust `AI_RETRY_MAX_ATTEMPTS` and `AI_RETRY_BASE_DELAY_MS` if rate-limited. - ---- - -#### Database migration failure - -**Symptoms:** Migration CLI exits with an error; service may fail to start -if a required table is missing. - -**Diagnosis:** -```bash -npx ts-node src/database/migration-cli.ts status -``` - -**Fix:** -1. Revert the failing migration script. -2. Run `rollback` to undo any partial changes. -3. Fix the migration and redeploy. -4. If data was mutated, see [Section 10.3](#103-recovering-deleted-records). - ---- - -## 12. Troubleshooting Guide - -### JWT errors / users getting logged out unexpectedly - -- Check `JWT_SECRET` matches between app instances (in multi-instance setups). -- Adjust `JWT_CLOCK_SKEW_SECONDS` if distributed clocks are drifting (`> 30 s`). -- Run `GET /health` to confirm the auth configuration is valid. - -### CORS errors in the browser - -- Check `CORS_ORIGIN` is set to the frontend origin (e.g., `https://app.rustacademy.xyz`). -- Do not use `*` in production if the frontend sends credentials. - -### Signed asset URL 403 errors - -- Verify `ASSET_SIGNING_SECRET` matches the value used when the URL was created. -- Check that the URL has not expired (`expiresAt` claim in the query string). - -### Jobs not processing - -- Check Redis is reachable: `redis-cli -h $REDIS_HOST ping` should return `PONG`. -- Check the dead-letter queue via `GET /api/v1/admin/dlq` (admin auth required). -- Inspect `JobsService` logs for error patterns. - -### Metrics not appearing - -- Prometheus metrics are exposed at `GET /metrics`. -- The `@willsoto/nestjs-prometheus` library is registered in `MonitoringModule`. -- If metrics return 404, confirm `MonitoringModule` is imported in `AppModule`. - ---- - -## 13. Health Checks & Monitoring - -### Health endpoint - -``` -GET /health -``` - -Response shape: - -```json -{ - "status": "ok", - "info": { - "database": { "status": "up" }, - "redis": { "status": "up" } - }, - "details": { ... } -} -``` - -Returns `503 Service Unavailable` if any critical dependency is down. - -### Prometheus metrics - -``` -GET /metrics -``` - -Key metrics: - -| Metric | Description | -|---------------------------------|----------------------------------------| -| `http_requests_total` | Request count by method / route / status | -| `http_request_duration_seconds` | Request latency histogram | -| `notification_delivery_total` | Notification deliveries by provider | -| `circuit_breaker_state` | Circuit state per provider (0=CLOSED, 1=OPEN) | - -### Recommended alerts - -| Condition | Severity | -|----------------------------------------------|----------| -| `http_requests_total{status="5xx"}` rate > 1% | P2 | -| Health endpoint returns non-200 | P1 | -| Circuit breaker OPEN for > 5 min | P2 | -| Database connection failures | P1 | - ---- - -## 14. Secrets Management - -> ⚠️ **Never commit secrets to the repository.** -> All `.env` files are listed in `.gitignore`. - -### Rotation procedure - -**JWT_SECRET rotation:** - -1. Generate a new secret: `openssl rand -hex 32` -2. Deploy the new secret alongside the old one (dual-secret verification period). -3. After all existing tokens expire (default: 24 h), remove the old secret. - -**ASSET_SIGNING_SECRET rotation:** - -1. Generate a new secret: `openssl rand -hex 32` -2. Deploy with the new secret. Old signed URLs will immediately become invalid. -3. Notify users with active signed URLs to regenerate them. - -**AI provider key rotation:** - -1. Generate a new key in the Anthropic/OpenAI dashboard. -2. Update `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` in the deployment config. -3. Redeploy. Zero-downtime rolling restart is safe. - -### Storage recommendations - -- Store production secrets in a dedicated secrets manager - (AWS Secrets Manager, HashiCorp Vault, Doppler, Railway Env). -- Do **not** store secrets in Git, CI logs, or Slack. -- Rotate secrets if they are accidentally exposed. diff --git a/BackendAcademy/envcheck.tmp.ts b/BackendAcademy/envcheck.tmp.ts deleted file mode 100644 index 142f8aa3ac..0000000000 --- a/BackendAcademy/envcheck.tmp.ts +++ /dev/null @@ -1,53 +0,0 @@ -import * as dotenv from 'dotenv'; -import * as fs from 'fs'; -import { envValidationSchema, ENV_VALIDATION_OPTIONS, NON_PRODUCTION_DEFAULTS } from './src/config/env.schema'; - -function run(label: string, env: Record) { - const { error, value } = envValidationSchema.validate(env, ENV_VALIDATION_OPTIONS); - console.log('---', label); - if (error) console.log(' ERROR:', error.message); - else console.log(' OK ', JSON.stringify({ - NODE_ENV: value.NODE_ENV, PORT: value.PORT, CORS_ORIGIN: value.CORS_ORIGIN, - DATABASE_URL: value.DATABASE_URL, REDIS_HOST: value.REDIS_HOST, REDIS_PORT: value.REDIS_PORT, - JWT_SECRET: value.JWT_SECRET, ASSET_SIGNING_SECRET: value.ASSET_SIGNING_SECRET, - AI_PROVIDER: value.AI_PROVIDER, ASSETS_MAX_SIZE_MB: value.ASSETS_MAX_SIZE_MB, - })); -} - -run('empty (development)', {}); -run('test', { NODE_ENV: 'test' }); -run('example env file', dotenv.parse(fs.readFileSync('.env.example'))); -run('production empty', { NODE_ENV: 'production' }); -run('production with placeholder secrets', { - NODE_ENV: 'production', - DATABASE_URL: 'postgresql://u:supersecretpw@db:5432/app', - REDIS_HOST: 'redis', - JWT_SECRET: 'change_me_in_production', - ASSET_SIGNING_SECRET: NON_PRODUCTION_DEFAULTS.development.ASSET_SIGNING_SECRET, -}); -run('production short secret', { - NODE_ENV: 'production', - DATABASE_URL: 'postgresql://u:supersecretpw@db:5432/app', - REDIS_HOST: 'redis', - JWT_SECRET: 'tooshort-abc', - ASSET_SIGNING_SECRET: 'x'.repeat(40), -}); -run('production bad db url', { - NODE_ENV: 'production', - DATABASE_URL: 'ftp://user:supersecretpw@db/app', - REDIS_HOST: 'redis', - JWT_SECRET: 'a'.repeat(40), - ASSET_SIGNING_SECRET: 'b'.repeat(40), -}); -run('production valid', { - NODE_ENV: 'production', - DATABASE_URL: 'postgresql://u:pw@db:5432/app', - REDIS_HOST: 'redis', - JWT_SECRET: 'a'.repeat(40), - ASSET_SIGNING_SECRET: 'b'.repeat(40), - CORS_ORIGIN: 'https://a.example, https://b.example', -}); -run('claude without key', { AI_PROVIDER: 'claude' }); -run('unknown vars', { PATH: '/usr/bin', SOME_RANDOM: 'x' }); -run('bad node env', { NODE_ENV: 'staging' }); -run('bad redis port', { REDIS_PORT: -1 }); diff --git a/BackendAcademy/eslint.config.ts b/BackendAcademy/eslint.config.ts deleted file mode 100644 index 00a72560a8..0000000000 --- a/BackendAcademy/eslint.config.ts +++ /dev/null @@ -1,55 +0,0 @@ -import eslint from '@eslint/js'; -import tseslint from 'typescript-eslint'; -import prettierConfig from 'eslint-config-prettier'; - -export default tseslint.config( - eslint.configs.recommended, - ...tseslint.configs.strictTypeChecked, - prettierConfig, - { - languageOptions: { - parserOptions: { - project: true, - tsconfigRootDir: __dirname, - }, - }, - rules: { - // NestJS convention: decorators use classes that look unused to TS - '@typescript-eslint/no-extraneous-class': 'off', - - // Allow empty constructors for DI - '@typescript-eslint/no-empty-function': ['error', { allow: ['constructors'] }], - - // Prefer explicit return types on public API surfaces - '@typescript-eslint/explicit-function-return-type': ['warn', { - allowExpressions: true, - allowTypedFunctionExpressions: true, - }], - - // Disallow floating promises (critical in async NestJS handlers) - '@typescript-eslint/no-floating-promises': 'error', - - // Consistent imports - '@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }], - - // No unused vars except those prefixed with _ - '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], - - // General - 'no-console': ['warn', { allow: ['warn', 'error'] }], - 'eqeqeq': ['error', 'always'], - }, - }, - { - // Relax rules in test files - files: ['**/*.spec.ts', '**/*.e2e-spec.ts'], - rules: { - '@typescript-eslint/explicit-function-return-type': 'off', - '@typescript-eslint/no-floating-promises': 'off', - '@typescript-eslint/no-unsafe-assignment': 'off', - }, - }, - { - ignores: ['dist/**', 'coverage/**', 'node_modules/**'], - }, -); diff --git a/BackendAcademy/jest.config.ts b/BackendAcademy/jest.config.ts deleted file mode 100644 index 736cfc18fc..0000000000 --- a/BackendAcademy/jest.config.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { Config } from 'jest'; - -const config: Config = { - moduleFileExtensions: ['js', 'json', 'ts'], - rootDir: 'src', - testRegex: '.*\\.spec\\.ts$', - transform: { - '^.+\\.(t|j)s$': ['ts-jest', { isolatedModules: true }], - }, - collectCoverageFrom: ['**/*.(t|j)s'], - coverageDirectory: '../coverage', - testEnvironment: 'node', - - // ── Flaky-test / shared-state mitigations (#451) ────────────── - clearMocks: true, - resetMocks: true, - restoreMocks: true, - - // Ensure every test file gets its own module registry so that - // module-level Maps / caches don't leak between suites. - resetModules: true, - - // ── Migration test configuration (#398) ────────────────── - // Exclude boilerplate from coverage to focus on business logic - coveragePathIgnorePatterns: [ - '/node_modules/', - '/dist/', - '/test/', - '\\.module\\.ts$', - '\\.dto\\.ts$', - '\\.entity\\.ts$', - 'main\\.ts$', - ], - -}; - -export default config; \ No newline at end of file diff --git a/BackendAcademy/nest-cli.json b/BackendAcademy/nest-cli.json index 256648114a..f9aa683b1a 100644 --- a/BackendAcademy/nest-cli.json +++ b/BackendAcademy/nest-cli.json @@ -1,5 +1,8 @@ { "$schema": "https://json.schemastore.org/nest-cli", "collection": "@nestjs/schematics", - "sourceRoot": "src" + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } } diff --git a/BackendAcademy/package-lock.json b/BackendAcademy/package-lock.json deleted file mode 100644 index f8d9a6341e..0000000000 --- a/BackendAcademy/package-lock.json +++ /dev/null @@ -1,12109 +0,0 @@ -{ - "name": "rustacademy-backend", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "rustacademy-backend", - "version": "0.1.0", - "dependencies": { - "@nestjs/common": "^10.4.22", - "@nestjs/config": "^3.3.0", - "@nestjs/core": "^10.0.0", - "@nestjs/jwt": "^10.2.0", - "@nestjs/platform-express": "^10.0.0", - "@nestjs/swagger": "^7.4.2", - "@nestjs/throttler": "^6.5.0", - "@nestjs/typeorm": "^10.0.2", - "@willsoto/nestjs-prometheus": "^6.0.0", - "axios": "^1.7.9", - "class-transformer": "^0.5.1", - "class-validator": "^0.14.4", - "file-type": "^19.6.0", - "joi": "^18.0.2", - "multer": "^2.2.0", - "prom-client": "^15.1.3", - "reflect-metadata": "^0.1.13", - "rxjs": "^7.8.1", - "typeorm": "^0.3.30", - "winston": "^3.19.0" - }, - "devDependencies": { - "@nestjs/cli": "^10.0.0", - "@nestjs/testing": "^10.0.0", - "@types/jest": "^30.0.0", - "@types/multer": "^1.4.12", - "@types/node": "^20.0.0", - "@types/uuid": "^10.0.0", - "eslint": "^8.0.0", - "jest": "^29.7.0", - "joi-to-typescript": "^4.2.0", - "ts-jest": "^29.1.0", - "ts-node": "^10.9.0", - "typescript": "^5.2.2", - "typescript-eslint": "^8.0.0" - } - }, - "node_modules/@angular-devkit/core": { - "version": "17.3.11", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-17.3.11.tgz", - "integrity": "sha512-vTNDYNsLIWpYk2I969LMQFH29GTsLzxNk/0cLw5q56ARF0v5sIWfHYwGTS88jdDqIpuuettcSczbxeA7EuAmqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "8.12.0", - "ajv-formats": "2.1.1", - "jsonc-parser": "3.2.1", - "picomatch": "4.0.1", - "rxjs": "7.8.1", - "source-map": "0.7.4" - }, - "engines": { - "node": "^18.13.0 || >=20.9.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - }, - "peerDependencies": { - "chokidar": "^3.5.2" - }, - "peerDependenciesMeta": { - "chokidar": { - "optional": true - } - } - }, - "node_modules/@angular-devkit/core/node_modules/rxjs": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", - "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - } - }, - "node_modules/@angular-devkit/schematics": { - "version": "17.3.11", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-17.3.11.tgz", - "integrity": "sha512-I5wviiIqiFwar9Pdk30Lujk8FczEEc18i22A5c6Z9lbmhPQdTroDnEQdsfXjy404wPe8H62s0I15o4pmMGfTYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@angular-devkit/core": "17.3.11", - "jsonc-parser": "3.2.1", - "magic-string": "0.30.8", - "ora": "5.4.1", - "rxjs": "7.8.1" - }, - "engines": { - "node": "^18.13.0 || >=20.9.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - } - }, - "node_modules/@angular-devkit/schematics-cli": { - "version": "17.3.11", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics-cli/-/schematics-cli-17.3.11.tgz", - "integrity": "sha512-kcOMqp+PHAKkqRad7Zd7PbpqJ0LqLaNZdY1+k66lLWmkEBozgq8v4ASn/puPWf9Bo0HpCiK+EzLf0VHE8Z/y6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@angular-devkit/core": "17.3.11", - "@angular-devkit/schematics": "17.3.11", - "ansi-colors": "4.1.3", - "inquirer": "9.2.15", - "symbol-observable": "4.0.0", - "yargs-parser": "21.1.1" - }, - "bin": { - "schematics": "bin/schematics.js" - }, - "engines": { - "node": "^18.13.0 || >=20.9.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - } - }, - "node_modules/@angular-devkit/schematics-cli/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@angular-devkit/schematics-cli/node_modules/cli-width": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", - "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">= 12" - } - }, - "node_modules/@angular-devkit/schematics-cli/node_modules/inquirer": { - "version": "9.2.15", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-9.2.15.tgz", - "integrity": "sha512-vI2w4zl/mDluHt9YEQ/543VTCwPKWiHzKtm9dM2V0NdFcqEexDAjUHzO1oA60HRNaVifGXXM1tRRNluLVHa0Kg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@ljharb/through": "^2.3.12", - "ansi-escapes": "^4.3.2", - "chalk": "^5.3.0", - "cli-cursor": "^3.1.0", - "cli-width": "^4.1.0", - "external-editor": "^3.1.0", - "figures": "^3.2.0", - "lodash": "^4.17.21", - "mute-stream": "1.0.0", - "ora": "^5.4.1", - "run-async": "^3.0.0", - "rxjs": "^7.8.1", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^6.2.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@angular-devkit/schematics-cli/node_modules/mute-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-1.0.0.tgz", - "integrity": "sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/@angular-devkit/schematics-cli/node_modules/run-async": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/run-async/-/run-async-3.0.0.tgz", - "integrity": "sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/@angular-devkit/schematics/node_modules/rxjs": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", - "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", - "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.29.7", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", - "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", - "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-compilation-targets": "^7.29.7", - "@babel/helper-module-transforms": "^7.29.7", - "@babel/helpers": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/generator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", - "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", - "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.29.7", - "@babel/helper-validator-option": "^7.29.7", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", - "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", - "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", - "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7", - "@babel/traverse": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", - "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", - "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", - "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.7" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-syntax-async-generators": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", - "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-bigint": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", - "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-class-properties": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", - "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.12.13" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-class-static-block": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", - "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.29.7.tgz", - "integrity": "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-import-meta": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", - "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-json-strings": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", - "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", - "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-logical-assignment-operators": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", - "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", - "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-numeric-separator": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", - "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-object-rest-spread": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", - "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-optional-catch-binding": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", - "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-optional-chaining": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", - "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-private-property-in-object": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", - "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-top-level-await": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", - "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", - "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/template": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", - "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", - "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@bcoe/v8-coverage": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", - "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@borewit/text-codec": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", - "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, - "node_modules/@colors/colors": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", - "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.1.90" - } - }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - }, - "node_modules/@dabh/diagnostics": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz", - "integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==", - "license": "MIT", - "dependencies": { - "@so-ric/colorspace": "^1.1.6", - "enabled": "2.0.x", - "kuler": "^2.0.0" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", - "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@eslint/js": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", - "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/@hapi/address": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", - "integrity": "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==", - "license": "BSD-3-Clause", - "dependencies": { - "@hapi/hoek": "^11.0.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@hapi/formula": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@hapi/formula/-/formula-3.0.2.tgz", - "integrity": "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==", - "license": "BSD-3-Clause" - }, - "node_modules/@hapi/hoek": { - "version": "11.0.7", - "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", - "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@hapi/pinpoint": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@hapi/pinpoint/-/pinpoint-2.0.1.tgz", - "integrity": "sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==", - "license": "BSD-3-Clause" - }, - "node_modules/@hapi/tlds": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.7.tgz", - "integrity": "sha512-MgNjRwy9Ti92yVAixLmDc8dd1bJIKwO9qlWCfFQRwRmUEDPQHYn4G6hwPFvFGUTzAa0FsS+inMjLin7GnyBRhA==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@hapi/topo": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-6.0.2.tgz", - "integrity": "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==", - "license": "BSD-3-Clause", - "dependencies": { - "@hapi/hoek": "^11.0.2" - } - }, - "node_modules/@humanwhocodes/config-array": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", - "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", - "deprecated": "Use @eslint/config-array instead", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanwhocodes/object-schema": "^2.0.3", - "debug": "^4.3.1", - "minimatch": "^3.0.5" - }, - "engines": { - "node": ">=10.10.0" - } - }, - "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", - "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", - "deprecated": "Use @eslint/object-schema instead", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "license": "MIT" - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/@istanbuljs/load-nyc-config": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", - "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "camelcase": "^5.3.1", - "find-up": "^4.1.0", - "get-package-type": "^0.1.0", - "js-yaml": "^3.13.1", - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", - "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", - "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@jest/console": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", - "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/console/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@jest/console/node_modules/jest-message-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", - "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.12.13", - "@jest/types": "^29.6.3", - "@types/stack-utils": "^2.0.0", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/console/node_modules/jest-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/console/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/@jest/console/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/core": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", - "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/console": "^29.7.0", - "@jest/reporters": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "exit": "^0.1.2", - "graceful-fs": "^4.2.9", - "jest-changed-files": "^29.7.0", - "jest-config": "^29.7.0", - "jest-haste-map": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-resolve-dependencies": "^29.7.0", - "jest-runner": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "jest-watcher": "^29.7.0", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/@jest/core/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@jest/core/node_modules/jest-message-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", - "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.12.13", - "@jest/types": "^29.6.3", - "@types/stack-utils": "^2.0.0", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/core/node_modules/jest-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/core/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/@jest/core/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/diff-sequences": { - "version": "30.4.0", - "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.4.0.tgz", - "integrity": "sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/environment": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", - "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-mock": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/environment/node_modules/jest-mock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", - "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/environment/node_modules/jest-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/environment/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/@jest/expect": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "expect": "^29.7.0", - "jest-snapshot": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/expect-utils": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.4.1.tgz", - "integrity": "sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/get-type": "30.1.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/expect/node_modules/@jest/expect-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", - "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "jest-get-type": "^29.6.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/expect/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@jest/expect/node_modules/expect": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/expect-utils": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/expect/node_modules/jest-diff": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", - "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.0.0", - "diff-sequences": "^29.6.3", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/expect/node_modules/jest-matcher-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", - "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.0.0", - "jest-diff": "^29.7.0", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/expect/node_modules/jest-message-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", - "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.12.13", - "@jest/types": "^29.6.3", - "@types/stack-utils": "^2.0.0", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/expect/node_modules/jest-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/expect/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/@jest/expect/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/fake-timers": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", - "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@sinonjs/fake-timers": "^10.0.2", - "@types/node": "*", - "jest-message-util": "^29.7.0", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/fake-timers/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@jest/fake-timers/node_modules/jest-message-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", - "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.12.13", - "@jest/types": "^29.6.3", - "@types/stack-utils": "^2.0.0", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/fake-timers/node_modules/jest-mock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", - "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/fake-timers/node_modules/jest-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/fake-timers/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/@jest/fake-timers/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/get-type": { - "version": "30.1.0", - "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", - "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/globals": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", - "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/expect": "^29.7.0", - "@jest/types": "^29.6.3", - "jest-mock": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/globals/node_modules/jest-mock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", - "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/globals/node_modules/jest-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/globals/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/@jest/pattern": { - "version": "30.4.0", - "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.4.0.tgz", - "integrity": "sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "jest-regex-util": "30.4.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/pattern/node_modules/jest-regex-util": { - "version": "30.4.0", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.4.0.tgz", - "integrity": "sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/reporters": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", - "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@jridgewell/trace-mapping": "^0.3.18", - "@types/node": "*", - "chalk": "^4.0.0", - "collect-v8-coverage": "^1.0.0", - "exit": "^0.1.2", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "istanbul-lib-coverage": "^3.0.0", - "istanbul-lib-instrument": "^6.0.0", - "istanbul-lib-report": "^3.0.0", - "istanbul-lib-source-maps": "^4.0.0", - "istanbul-reports": "^3.1.3", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "jest-worker": "^29.7.0", - "slash": "^3.0.0", - "string-length": "^4.0.1", - "strip-ansi": "^6.0.0", - "v8-to-istanbul": "^9.0.1" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/@jest/reporters/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@jest/reporters/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@jest/reporters/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@jest/reporters/node_modules/jest-message-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", - "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.12.13", - "@jest/types": "^29.6.3", - "@types/stack-utils": "^2.0.0", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/reporters/node_modules/jest-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/reporters/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@jest/reporters/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/@jest/reporters/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/schemas": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", - "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sinclair/typebox": "^0.27.8" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/source-map": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", - "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.18", - "callsites": "^3.0.0", - "graceful-fs": "^4.2.9" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/test-result": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", - "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/console": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "collect-v8-coverage": "^1.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/test-sequencer": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", - "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/test-result": "^29.7.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/transform": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", - "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.11.6", - "@jest/types": "^29.6.3", - "@jridgewell/trace-mapping": "^0.3.18", - "babel-plugin-istanbul": "^6.1.1", - "chalk": "^4.0.0", - "convert-source-map": "^2.0.0", - "fast-json-stable-stringify": "^2.1.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-util": "^29.7.0", - "micromatch": "^4.0.4", - "pirates": "^4.0.4", - "slash": "^3.0.0", - "write-file-atomic": "^4.0.2" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/transform/node_modules/jest-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/transform/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/@jest/types": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", - "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", - "@types/node": "*", - "@types/yargs": "^17.0.8", - "chalk": "^4.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/source-map": { - "version": "0.3.11", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", - "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@ljharb/through": { - "version": "2.3.14", - "resolved": "https://registry.npmjs.org/@ljharb/through/-/through-2.3.14.tgz", - "integrity": "sha512-ajBvlKpWucBB17FuQYUShqpqy8GRgYEpJW0vWJbUu1CV9lWyrDCapy0lScU8T8Z6qn49sSwJB3+M+evYIdGg+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/@lukeed/csprng": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz", - "integrity": "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@microsoft/tsdoc": { - "version": "0.15.1", - "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.15.1.tgz", - "integrity": "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==", - "license": "MIT" - }, - "node_modules/@nestjs/cli": { - "version": "10.4.9", - "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.9.tgz", - "integrity": "sha512-s8qYd97bggqeK7Op3iD49X2MpFtW4LVNLAwXFkfbRxKME6IYT7X0muNTJ2+QfI8hpbNx9isWkrLWIp+g5FOhiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@angular-devkit/core": "17.3.11", - "@angular-devkit/schematics": "17.3.11", - "@angular-devkit/schematics-cli": "17.3.11", - "@nestjs/schematics": "^10.0.1", - "chalk": "4.1.2", - "chokidar": "3.6.0", - "cli-table3": "0.6.5", - "commander": "4.1.1", - "fork-ts-checker-webpack-plugin": "9.0.2", - "glob": "10.4.5", - "inquirer": "8.2.6", - "node-emoji": "1.11.0", - "ora": "5.4.1", - "tree-kill": "1.2.2", - "tsconfig-paths": "4.2.0", - "tsconfig-paths-webpack-plugin": "4.2.0", - "typescript": "5.7.2", - "webpack": "5.97.1", - "webpack-node-externals": "3.0.0" - }, - "bin": { - "nest": "bin/nest.js" - }, - "engines": { - "node": ">= 16.14" - }, - "peerDependencies": { - "@swc/cli": "^0.1.62 || ^0.3.0 || ^0.4.0 || ^0.5.0", - "@swc/core": "^1.3.62" - }, - "peerDependenciesMeta": { - "@swc/cli": { - "optional": true - }, - "@swc/core": { - "optional": true - } - } - }, - "node_modules/@nestjs/cli/node_modules/typescript": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", - "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/@nestjs/common": { - "version": "10.4.22", - "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-10.4.22.tgz", - "integrity": "sha512-fxJ4v85nDHaqT1PmfNCQ37b/jcv2OojtXTaK1P2uAXhzLf9qq6WNUOFvxBrV4fhQek1EQoT1o9oj5xAZmv3NRw==", - "license": "MIT", - "dependencies": { - "file-type": "20.4.1", - "iterare": "1.2.1", - "tslib": "2.8.1", - "uid": "2.0.2" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/nest" - }, - "peerDependencies": { - "class-transformer": "*", - "class-validator": "*", - "reflect-metadata": "^0.1.12 || ^0.2.0", - "rxjs": "^7.1.0" - }, - "peerDependenciesMeta": { - "class-transformer": { - "optional": true - }, - "class-validator": { - "optional": true - } - } - }, - "node_modules/@nestjs/common/node_modules/file-type": { - "version": "20.4.1", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-20.4.1.tgz", - "integrity": "sha512-hw9gNZXUfZ02Jo0uafWLaFVPter5/k2rfcrjFJJHX/77xtSDOfJuEFb6oKlFV86FLP1SuyHMW1PSk0U9M5tKkQ==", - "license": "MIT", - "dependencies": { - "@tokenizer/inflate": "^0.2.6", - "strtok3": "^10.2.0", - "token-types": "^6.0.0", - "uint8array-extras": "^1.4.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sindresorhus/file-type?sponsor=1" - } - }, - "node_modules/@nestjs/common/node_modules/strtok3": { - "version": "10.3.5", - "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", - "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", - "license": "MIT", - "dependencies": { - "@tokenizer/token": "^0.3.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, - "node_modules/@nestjs/config": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/@nestjs/config/-/config-3.3.0.tgz", - "integrity": "sha512-pdGTp8m9d0ZCrjTpjkUbZx6gyf2IKf+7zlkrPNMsJzYZ4bFRRTpXrnj+556/5uiI6AfL5mMrJc2u7dB6bvM+VA==", - "license": "MIT", - "dependencies": { - "dotenv": "16.4.5", - "dotenv-expand": "10.0.0", - "lodash": "4.17.21" - }, - "peerDependencies": { - "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", - "rxjs": "^7.1.0" - } - }, - "node_modules/@nestjs/core": { - "version": "10.4.22", - "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-10.4.22.tgz", - "integrity": "sha512-6IX9+VwjiKtCjx+mXVPncpkQ5ZjKfmssOZPFexmT+6T9H9wZ3svpYACAo7+9e7Nr9DZSoRZw3pffkJP7Z0UjaA==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "@nuxtjs/opencollective": "0.3.2", - "fast-safe-stringify": "2.1.1", - "iterare": "1.2.1", - "path-to-regexp": "3.3.0", - "tslib": "2.8.1", - "uid": "2.0.2" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/nest" - }, - "peerDependencies": { - "@nestjs/common": "^10.0.0", - "@nestjs/microservices": "^10.0.0", - "@nestjs/platform-express": "^10.0.0", - "@nestjs/websockets": "^10.0.0", - "reflect-metadata": "^0.1.12 || ^0.2.0", - "rxjs": "^7.1.0" - }, - "peerDependenciesMeta": { - "@nestjs/microservices": { - "optional": true - }, - "@nestjs/platform-express": { - "optional": true - }, - "@nestjs/websockets": { - "optional": true - } - } - }, - "node_modules/@nestjs/jwt": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-10.2.0.tgz", - "integrity": "sha512-x8cG90SURkEiLOehNaN2aRlotxT0KZESUliOPKKnjWiyJOcWurkF3w345WOX0P4MgFzUjGoZ1Sy0aZnxeihT0g==", - "license": "MIT", - "dependencies": { - "@types/jsonwebtoken": "9.0.5", - "jsonwebtoken": "9.0.2" - }, - "peerDependencies": { - "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0" - } - }, - "node_modules/@nestjs/mapped-types": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.0.5.tgz", - "integrity": "sha512-bSJv4pd6EY99NX9CjBIyn4TVDoSit82DUZlL4I3bqNfy5Gt+gXTa86i3I/i0iIV9P4hntcGM5GyO+FhZAhxtyg==", - "license": "MIT", - "peerDependencies": { - "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", - "class-transformer": "^0.4.0 || ^0.5.0", - "class-validator": "^0.13.0 || ^0.14.0", - "reflect-metadata": "^0.1.12 || ^0.2.0" - }, - "peerDependenciesMeta": { - "class-transformer": { - "optional": true - }, - "class-validator": { - "optional": true - } - } - }, - "node_modules/@nestjs/platform-express": { - "version": "10.4.22", - "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.22.tgz", - "integrity": "sha512-ySSq7Py/DFozzZdNDH67m/vHoeVdphDniWBnl6q5QVoXldDdrZIHLXLRMPayTDh5A95nt7jjJzmD4qpTbNQ6tA==", - "license": "MIT", - "dependencies": { - "body-parser": "1.20.4", - "cors": "2.8.5", - "express": "4.22.1", - "multer": "2.0.2", - "tslib": "2.8.1" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/nest" - }, - "peerDependencies": { - "@nestjs/common": "^10.0.0", - "@nestjs/core": "^10.0.0" - } - }, - "node_modules/@nestjs/platform-express/node_modules/multer": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", - "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", - "license": "MIT", - "dependencies": { - "append-field": "^1.0.0", - "busboy": "^1.6.0", - "concat-stream": "^2.0.0", - "mkdirp": "^0.5.6", - "object-assign": "^4.1.1", - "type-is": "^1.6.18", - "xtend": "^4.0.2" - }, - "engines": { - "node": ">= 10.16.0" - } - }, - "node_modules/@nestjs/schematics": { - "version": "10.2.3", - "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.2.3.tgz", - "integrity": "sha512-4e8gxaCk7DhBxVUly2PjYL4xC2ifDFexCqq1/u4TtivLGXotVk0wHdYuPYe1tHTHuR1lsOkRbfOCpkdTnigLVg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@angular-devkit/core": "17.3.11", - "@angular-devkit/schematics": "17.3.11", - "comment-json": "4.2.5", - "jsonc-parser": "3.3.1", - "pluralize": "8.0.0" - }, - "peerDependencies": { - "typescript": ">=4.8.2" - } - }, - "node_modules/@nestjs/schematics/node_modules/jsonc-parser": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", - "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@nestjs/swagger": { - "version": "7.4.2", - "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-7.4.2.tgz", - "integrity": "sha512-Mu6TEn1M/owIvAx2B4DUQObQXqo2028R2s9rSZ/hJEgBK95+doTwS0DjmVA2wTeZTyVtXOoN7CsoM5pONBzvKQ==", - "license": "MIT", - "dependencies": { - "@microsoft/tsdoc": "^0.15.0", - "@nestjs/mapped-types": "2.0.5", - "js-yaml": "4.1.0", - "lodash": "4.17.21", - "path-to-regexp": "3.3.0", - "swagger-ui-dist": "5.17.14" - }, - "peerDependencies": { - "@fastify/static": "^6.0.0 || ^7.0.0", - "@nestjs/common": "^9.0.0 || ^10.0.0", - "@nestjs/core": "^9.0.0 || ^10.0.0", - "class-transformer": "*", - "class-validator": "*", - "reflect-metadata": "^0.1.12 || ^0.2.0" - }, - "peerDependenciesMeta": { - "@fastify/static": { - "optional": true - }, - "class-transformer": { - "optional": true - }, - "class-validator": { - "optional": true - } - } - }, - "node_modules/@nestjs/testing": { - "version": "10.4.22", - "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.4.22.tgz", - "integrity": "sha512-HO9aPus3bAedAC+jKVAA8jTdaj4fs5M9fing4giHrcYV2txe9CvC1l1WAjwQ9RDhEHdugjY4y+FZA/U/YqPZrA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "2.8.1" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/nest" - }, - "peerDependencies": { - "@nestjs/common": "^10.0.0", - "@nestjs/core": "^10.0.0", - "@nestjs/microservices": "^10.0.0", - "@nestjs/platform-express": "^10.0.0" - }, - "peerDependenciesMeta": { - "@nestjs/microservices": { - "optional": true - }, - "@nestjs/platform-express": { - "optional": true - } - } - }, - "node_modules/@nestjs/throttler": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-6.5.0.tgz", - "integrity": "sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==", - "license": "MIT", - "peerDependencies": { - "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", - "@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", - "reflect-metadata": "^0.1.13 || ^0.2.0" - } - }, - "node_modules/@nestjs/typeorm": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-10.0.2.tgz", - "integrity": "sha512-H738bJyydK4SQkRCTeh1aFBxoO1E9xdL/HaLGThwrqN95os5mEyAtK7BLADOS+vldP4jDZ2VQPLj4epWwRqCeQ==", - "license": "MIT", - "dependencies": { - "uuid": "9.0.1" - }, - "peerDependencies": { - "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", - "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0", - "reflect-metadata": "^0.1.13 || ^0.2.0", - "rxjs": "^7.2.0", - "typeorm": "^0.3.0" - } - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nuxtjs/opencollective": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@nuxtjs/opencollective/-/opencollective-0.3.2.tgz", - "integrity": "sha512-um0xL3fO7Mf4fDxcqx9KryrB7zgRM5JSlvGN5AGkP6JLM5XEKyjeAiPbNxdXVXQ16isuAhYpvP88NgL2BGd6aA==", - "license": "MIT", - "dependencies": { - "chalk": "^4.1.0", - "consola": "^2.15.0", - "node-fetch": "^2.6.1" - }, - "bin": { - "opencollective": "bin/opencollective.js" - }, - "engines": { - "node": ">=8.0.0", - "npm": ">=5.0.0" - } - }, - "node_modules/@opentelemetry/api": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", - "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", - "license": "Apache-2.0", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/@sec-ant/readable-stream": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", - "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", - "license": "MIT" - }, - "node_modules/@sinclair/typebox": { - "version": "0.27.12", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", - "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sinonjs/commons": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", - "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "type-detect": "4.0.8" - } - }, - "node_modules/@sinonjs/fake-timers": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", - "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@sinonjs/commons": "^3.0.0" - } - }, - "node_modules/@so-ric/colorspace": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", - "integrity": "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==", - "license": "MIT", - "dependencies": { - "color": "^5.0.2", - "text-hex": "1.0.x" - } - }, - "node_modules/@sqltools/formatter": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.5.tgz", - "integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==", - "license": "MIT" - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "license": "MIT" - }, - "node_modules/@tokenizer/inflate": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz", - "integrity": "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "fflate": "^0.8.2", - "token-types": "^6.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, - "node_modules/@tokenizer/token": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", - "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", - "license": "MIT" - }, - "node_modules/@tsconfig/node10": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", - "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node12": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", - "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node14": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", - "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node16": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", - "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.2" - } - }, - "node_modules/@types/body-parser": { - "version": "1.19.6", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", - "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/connect": "*", - "@types/node": "*" - } - }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/eslint": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", - "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, - "node_modules/@types/eslint-scope": { - "version": "3.7.7", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", - "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/eslint": "*", - "@types/estree": "*" - } - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/express": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", - "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^5.0.0", - "@types/serve-static": "^2" - } - }, - "node_modules/@types/express-serve-static-core": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.2.tgz", - "integrity": "sha512-d3KvEXBSo/lOAMc2u6fkyDHBvetBHeqD7wm/AcXfLpSOQwlmG9D/aQ0SFswVjv05p7ullQS7Mjohj6/VdbZuTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } - }, - "node_modules/@types/graceful-fs": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", - "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/http-errors": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", - "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/istanbul-lib-coverage": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", - "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/istanbul-lib-report": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", - "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/istanbul-lib-coverage": "*" - } - }, - "node_modules/@types/istanbul-reports": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", - "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/istanbul-lib-report": "*" - } - }, - "node_modules/@types/jest": { - "version": "30.0.0", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz", - "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==", - "dev": true, - "license": "MIT", - "dependencies": { - "expect": "^30.0.0", - "pretty-format": "^30.0.0" - } - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/jsonwebtoken": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.5.tgz", - "integrity": "sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/multer": { - "version": "1.4.13", - "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.13.tgz", - "integrity": "sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/express": "*" - } - }, - "node_modules/@types/node": { - "version": "20.19.43", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", - "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@types/qs": { - "version": "6.15.1", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", - "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/range-parser": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", - "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/serve-static": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", - "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/http-errors": "*", - "@types/node": "*" - } - }, - "node_modules/@types/stack-utils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", - "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/triple-beam": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", - "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", - "license": "MIT" - }, - "node_modules/@types/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/validator": { - "version": "13.15.10", - "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", - "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", - "license": "MIT" - }, - "node_modules/@types/yargs": { - "version": "17.0.35", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", - "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/yargs-parser": "*" - } - }, - "node_modules/@types/yargs-parser": { - "version": "21.0.3", - "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", - "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.65.0.tgz", - "integrity": "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.65.0", - "@typescript-eslint/types": "^8.65.0", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/project-service/node_modules/@typescript-eslint/types": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz", - "integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.65.0.tgz", - "integrity": "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", - "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", - "dev": true, - "license": "ISC" - }, - "node_modules/@webassemblyjs/ast": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", - "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@webassemblyjs/helper-numbers": "1.13.2", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2" - } - }, - "node_modules/@webassemblyjs/floating-point-hex-parser": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", - "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@webassemblyjs/helper-api-error": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", - "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@webassemblyjs/helper-buffer": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", - "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@webassemblyjs/helper-numbers": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", - "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@webassemblyjs/floating-point-hex-parser": "1.13.2", - "@webassemblyjs/helper-api-error": "1.13.2", - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@webassemblyjs/helper-wasm-bytecode": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", - "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@webassemblyjs/helper-wasm-section": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", - "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/wasm-gen": "1.14.1" - } - }, - "node_modules/@webassemblyjs/ieee754": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", - "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@xtuc/ieee754": "^1.2.0" - } - }, - "node_modules/@webassemblyjs/leb128": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", - "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@webassemblyjs/utf8": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", - "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@webassemblyjs/wasm-edit": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", - "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/helper-wasm-section": "1.14.1", - "@webassemblyjs/wasm-gen": "1.14.1", - "@webassemblyjs/wasm-opt": "1.14.1", - "@webassemblyjs/wasm-parser": "1.14.1", - "@webassemblyjs/wast-printer": "1.14.1" - } - }, - "node_modules/@webassemblyjs/wasm-gen": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", - "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/ieee754": "1.13.2", - "@webassemblyjs/leb128": "1.13.2", - "@webassemblyjs/utf8": "1.13.2" - } - }, - "node_modules/@webassemblyjs/wasm-opt": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", - "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/wasm-gen": "1.14.1", - "@webassemblyjs/wasm-parser": "1.14.1" - } - }, - "node_modules/@webassemblyjs/wasm-parser": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", - "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-api-error": "1.13.2", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/ieee754": "1.13.2", - "@webassemblyjs/leb128": "1.13.2", - "@webassemblyjs/utf8": "1.13.2" - } - }, - "node_modules/@webassemblyjs/wast-printer": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", - "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@willsoto/nestjs-prometheus": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@willsoto/nestjs-prometheus/-/nestjs-prometheus-6.1.0.tgz", - "integrity": "sha512-lrCEnJBBSzUIYWGR+PsZw1YXs1B9jzxFEuNAa3RzTxuFAFdI+sW7Fp52il/U/dX2MWoHc32x06OS0nm56QwyzQ==", - "license": "Apache-2.0", - "peerDependencies": { - "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", - "prom-client": "^15.0.0" - } - }, - "node_modules/@xtuc/ieee754": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", - "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/@xtuc/long": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", - "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "license": "MIT", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/acorn": { - "version": "8.17.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", - "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/acorn-walk": { - "version": "8.3.5", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", - "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "license": "MIT", - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" - } - }, - "node_modules/ansi-colors": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", - "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "type-fest": "^0.21.3" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-escapes/node_modules/type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/ansis": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.3.1.tgz", - "integrity": "sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==", - "license": "ISC", - "engines": { - "node": ">=14" - } - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/app-root-path": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/app-root-path/-/app-root-path-3.1.0.tgz", - "integrity": "sha512-biN3PwB2gUtjaYy/isrU3aNWI5w+fAfvHkSvCKeQGxhmYpwKFUxudR3Yya+KqVRHBmEDYh+/lTozYCFbmzX4nA==", - "license": "MIT", - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/append-field": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", - "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", - "license": "MIT" - }, - "node_modules/arg": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true, - "license": "MIT" - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "license": "Python-2.0" - }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" - }, - "node_modules/array-timsort": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz", - "integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/async": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", - "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", - "license": "MIT" - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "license": "MIT", - "dependencies": { - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/axios": { - "version": "1.20.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", - "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.16.0", - "form-data": "^4.0.6", - "https-proxy-agent": "^5.0.1", - "proxy-from-env": "^2.1.0" - } - }, - "node_modules/babel-jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", - "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/transform": "^29.7.0", - "@types/babel__core": "^7.1.14", - "babel-plugin-istanbul": "^6.1.1", - "babel-preset-jest": "^29.6.3", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.8.0" - } - }, - "node_modules/babel-plugin-istanbul": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", - "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@babel/helper-plugin-utils": "^7.0.0", - "@istanbuljs/load-nyc-config": "^1.0.0", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-instrument": "^5.0.4", - "test-exclude": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", - "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@babel/core": "^7.12.3", - "@babel/parser": "^7.14.7", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^6.3.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/babel-plugin-istanbul/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/babel-plugin-jest-hoist": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", - "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.3.3", - "@babel/types": "^7.3.3", - "@types/babel__core": "^7.1.14", - "@types/babel__traverse": "^7.0.6" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/babel-preset-current-node-syntax": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", - "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/plugin-syntax-async-generators": "^7.8.4", - "@babel/plugin-syntax-bigint": "^7.8.3", - "@babel/plugin-syntax-class-properties": "^7.12.13", - "@babel/plugin-syntax-class-static-block": "^7.14.5", - "@babel/plugin-syntax-import-attributes": "^7.24.7", - "@babel/plugin-syntax-import-meta": "^7.10.4", - "@babel/plugin-syntax-json-strings": "^7.8.3", - "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", - "@babel/plugin-syntax-numeric-separator": "^7.10.4", - "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", - "@babel/plugin-syntax-optional-chaining": "^7.8.3", - "@babel/plugin-syntax-private-property-in-object": "^7.14.5", - "@babel/plugin-syntax-top-level-await": "^7.14.5" - }, - "peerDependencies": { - "@babel/core": "^7.0.0 || ^8.0.0-0" - } - }, - "node_modules/babel-preset-jest": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", - "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", - "dev": true, - "license": "MIT", - "dependencies": { - "babel-plugin-jest-hoist": "^29.6.3", - "babel-preset-current-node-syntax": "^1.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/baseline-browser-mapping": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.4.tgz", - "integrity": "sha512-s4+sLr9mZ/CyqeRritFeYV/Zx73OAtmaHn6kkBS1XRoJn1hrg3xIDUcpicAEX68tkcIN0iBCgti31C8zxtkhsQ==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/bintrees": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz", - "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==", - "license": "MIT" - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/body-parser": { - "version": "1.20.4", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", - "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.14.0", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/body-parser/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/body-parser/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/brace-expansion": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", - "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.28.7", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", - "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.10.44", - "caniuse-lite": "^1.0.30001806", - "electron-to-chromium": "^1.5.393", - "node-releases": "^2.0.51", - "update-browserslist-db": "^1.2.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/bs-logger": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", - "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-json-stable-stringify": "2.x" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/bser": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", - "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "node-int64": "^0.4.0" - } - }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/buffer-equal-constant-time": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "license": "BSD-3-Clause" - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "license": "MIT" - }, - "node_modules/busboy": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", - "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", - "dependencies": { - "streamsearch": "^1.1.0" - }, - "engines": { - "node": ">=10.16.0" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", - "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "get-intrinsic": "^1.3.0", - "set-function-length": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001806", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", - "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/char-regex": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", - "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/chardet": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", - "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", - "dev": true, - "license": "MIT" - }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chrome-trace-event": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", - "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0" - } - }, - "node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cjs-module-lexer": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", - "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/class-transformer": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", - "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "license": "MIT" - }, - "node_modules/class-validator": { - "version": "0.14.4", - "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.4.tgz", - "integrity": "sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==", - "license": "MIT", - "dependencies": { - "@types/validator": "^13.15.3", - "libphonenumber-js": "^1.11.1", - "validator": "^13.15.22" - } - }, - "node_modules/cli-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", - "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "restore-cursor": "^3.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cli-spinners": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", - "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-table3": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", - "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "string-width": "^4.2.0" - }, - "engines": { - "node": "10.* || >= 12.*" - }, - "optionalDependencies": { - "@colors/colors": "1.5.0" - } - }, - "node_modules/cli-width": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", - "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">= 10" - } - }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/cliui/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/clone": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", - "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8" - } - }, - "node_modules/co": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", - "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">= 1.0.0", - "node": ">= 0.12.0" - } - }, - "node_modules/collect-v8-coverage": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", - "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", - "dev": true, - "license": "MIT" - }, - "node_modules/color": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", - "integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==", - "license": "MIT", - "dependencies": { - "color-convert": "^3.1.3", - "color-string": "^2.1.3" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/color-string": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz", - "integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==", - "license": "MIT", - "dependencies": { - "color-name": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/color-string/node_modules/color-name": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.1.tgz", - "integrity": "sha512-p2FdgwVx1a9yWBHP2wI0VgShkDpgN4kZISkxdNipGBJWpa5G6b04OINlVWCyJj0JmfvcPrgqt95E9k8yvaOJFg==", - "license": "MIT", - "engines": { - "node": ">=12.20" - } - }, - "node_modules/color/node_modules/color-convert": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz", - "integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==", - "license": "MIT", - "dependencies": { - "color-name": "^2.0.0" - }, - "engines": { - "node": ">=14.6" - } - }, - "node_modules/color/node_modules/color-name": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.1.tgz", - "integrity": "sha512-p2FdgwVx1a9yWBHP2wI0VgShkDpgN4kZISkxdNipGBJWpa5G6b04OINlVWCyJj0JmfvcPrgqt95E9k8yvaOJFg==", - "license": "MIT", - "engines": { - "node": ">=12.20" - } - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/comment-json": { - "version": "4.2.5", - "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.2.5.tgz", - "integrity": "sha512-bKw/r35jR3HGt5PEPm1ljsQQGyCrR8sFGNiN5L+ykDHdpO8Smxkrkla9Yi6NkQyUrb8V54PGhfMs6NrIwtxtdw==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-timsort": "^1.0.3", - "core-util-is": "^1.0.3", - "esprima": "^4.0.1", - "has-own-prop": "^2.0.0", - "repeat-string": "^1.6.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/concat-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", - "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", - "engines": [ - "node >= 6.0" - ], - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.0.2", - "typedarray": "^0.0.6" - } - }, - "node_modules/consola": { - "version": "2.15.3", - "resolved": "https://registry.npmjs.org/consola/-/consola-2.15.3.tgz", - "integrity": "sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw==", - "license": "MIT" - }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "license": "MIT" - }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/cors": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", - "license": "MIT", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/cosmiconfig": { - "version": "8.3.6", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", - "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", - "dev": true, - "license": "MIT", - "dependencies": { - "import-fresh": "^3.3.0", - "js-yaml": "^4.1.0", - "parse-json": "^5.2.0", - "path-type": "^4.0.0" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" - }, - "peerDependencies": { - "typescript": ">=4.9.5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/create-jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", - "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "exit": "^0.1.2", - "graceful-fs": "^4.2.9", - "jest-config": "^29.7.0", - "jest-util": "^29.7.0", - "prompts": "^2.0.1" - }, - "bin": { - "create-jest": "bin/create-jest.js" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/create-jest/node_modules/jest-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/create-jest/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/create-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/dayjs": { - "version": "1.11.21", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", - "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/dedent": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", - "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", - "license": "MIT", - "peerDependencies": { - "babel-plugin-macros": "^3.1.0" - }, - "peerDependenciesMeta": { - "babel-plugin-macros": { - "optional": true - } - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/defaults": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", - "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "clone": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/detect-newline": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", - "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/diff": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", - "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/diff-sequences": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", - "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/dotenv": { - "version": "16.4.5", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", - "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/dotenv-expand": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-10.0.0.tgz", - "integrity": "sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "license": "MIT" - }, - "node_modules/ecdsa-sig-formatter": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, - "node_modules/electron-to-chromium": { - "version": "1.5.396", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.396.tgz", - "integrity": "sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/emittery": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", - "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sindresorhus/emittery?sponsor=1" - } - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/enabled": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", - "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==", - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/enhanced-resolve": { - "version": "5.24.3", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.3.tgz", - "integrity": "sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.3.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/error-ex": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", - "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" - }, - "node_modules/es-object-atoms": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", - "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", - "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.1", - "@humanwhocodes/config-array": "^0.13.0", - "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "@ungap/structured-clone": "^1.2.0", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", - "debug": "^4.3.2", - "doctrine": "^3.0.0", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "esquery": "^1.4.2", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "graphemer": "^1.4.0", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3", - "strip-ansi": "^6.0.1", - "text-table": "^0.2.0" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-scope": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/eslint/node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/eslint/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/espree": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.9.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/esquery": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", - "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.x" - } - }, - "node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/execa/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/exit": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", - "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", - "dev": true, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/expect": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/expect/-/expect-30.4.1.tgz", - "integrity": "sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/expect-utils": "30.4.1", - "@jest/get-type": "30.1.0", - "jest-matcher-utils": "30.4.1", - "jest-message-util": "30.4.1", - "jest-mock": "30.4.1", - "jest-util": "30.4.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/express": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", - "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", - "license": "MIT", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "~1.20.3", - "content-disposition": "~0.5.4", - "content-type": "~1.0.4", - "cookie": "~0.7.1", - "cookie-signature": "~1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "~1.3.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.12", - "proxy-addr": "~2.0.7", - "qs": "~6.14.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "~0.19.0", - "serve-static": "~1.16.2", - "setprototypeof": "1.2.0", - "statuses": "~2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/express/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/express/node_modules/path-to-regexp": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", - "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", - "license": "MIT" - }, - "node_modules/external-editor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", - "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", - "dev": true, - "license": "MIT", - "dependencies": { - "chardet": "^0.7.0", - "iconv-lite": "^0.4.24", - "tmp": "^0.0.33" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-safe-stringify": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", - "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", - "license": "MIT" - }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" - } - }, - "node_modules/fb-watchman": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", - "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "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/fecha": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", - "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", - "license": "MIT" - }, - "node_modules/fflate": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", - "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", - "license": "MIT" - }, - "node_modules/figures": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", - "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "escape-string-regexp": "^1.0.5" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/figures/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^3.0.4" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "node_modules/file-type": { - "version": "19.6.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-19.6.0.tgz", - "integrity": "sha512-VZR5I7k5wkD0HgFnMsq5hOsSc710MJMu5Nc5QYsbe38NN5iPV/XTObYLc/cpttRTf6lX538+5uO1ZQRhYibiZQ==", - "license": "MIT", - "dependencies": { - "get-stream": "^9.0.1", - "strtok3": "^9.0.1", - "token-types": "^6.0.0", - "uint8array-extras": "^1.3.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sindresorhus/file-type?sponsor=1" - } - }, - "node_modules/file-type/node_modules/get-stream": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", - "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", - "license": "MIT", - "dependencies": { - "@sec-ant/readable-stream": "^0.4.1", - "is-stream": "^4.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/file-type/node_modules/is-stream": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", - "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/finalhandler": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", - "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "statuses": "~2.0.2", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/finalhandler/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/finalhandler/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", - "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.3", - "rimraf": "^3.0.2" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, - "node_modules/flatted": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.3.tgz", - "integrity": "sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/fn.name": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", - "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==", - "license": "MIT" - }, - "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/for-each": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", - "license": "MIT", - "dependencies": { - "is-callable": "^1.2.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/fork-ts-checker-webpack-plugin": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-9.0.2.tgz", - "integrity": "sha512-Uochze2R8peoN1XqlSi/rGUkDQpRogtLFocP9+PGu68zk1BDAKXfdeCdyVZpgTk8V8WFVQXdEz426VKjXLO1Gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.16.7", - "chalk": "^4.1.2", - "chokidar": "^3.5.3", - "cosmiconfig": "^8.2.0", - "deepmerge": "^4.2.2", - "fs-extra": "^10.0.0", - "memfs": "^3.4.1", - "minimatch": "^3.0.4", - "node-abort-controller": "^3.0.1", - "schema-utils": "^3.1.1", - "semver": "^7.3.5", - "tapable": "^2.2.1" - }, - "engines": { - "node": ">=12.13.0", - "yarn": ">=1.0.0" - }, - "peerDependencies": { - "typescript": ">3.6.0", - "webpack": "^5.11.0" - } - }, - "node_modules/fork-ts-checker-webpack-plugin/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/fork-ts-checker-webpack-plugin/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/form-data": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", - "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.4", - "mime-types": "^2.1.35" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/fs-monkey": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.1.0.tgz", - "integrity": "sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==", - "dev": true, - "license": "Unlicense" - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, - "license": "ISC" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-package-type": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", - "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/glob-to-regexp": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/glob/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "type-fest": "^0.20.2" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, - "node_modules/handlebars": { - "version": "4.7.9", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", - "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "minimist": "^1.2.5", - "neo-async": "^2.6.2", - "source-map": "^0.6.1", - "wordwrap": "^1.0.0" - }, - "bin": { - "handlebars": "bin/handlebars" - }, - "engines": { - "node": ">=0.4.7" - }, - "optionalDependencies": { - "uglify-js": "^3.1.4" - } - }, - "node_modules/handlebars/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/has-own-prop": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/has-own-prop/-/has-own-prop-2.0.0.tgz", - "integrity": "sha512-Pq0h+hvsVm6dDEa8x82GnLSYHOzNDt7f0ddFa3FqcQlgzEiptPqL+XrOJNavjOzSYiYWIrgeVYYgGlLmnxwilQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true, - "license": "MIT" - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "license": "MIT", - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10.17.0" - } - }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/import-local": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", - "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pkg-dir": "^4.2.0", - "resolve-cwd": "^3.0.0" - }, - "bin": { - "import-local-fixture": "fixtures/cli.js" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/inquirer": { - "version": "8.2.6", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.6.tgz", - "integrity": "sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-escapes": "^4.2.1", - "chalk": "^4.1.1", - "cli-cursor": "^3.1.0", - "cli-width": "^3.0.0", - "external-editor": "^3.0.3", - "figures": "^3.0.0", - "lodash": "^4.17.21", - "mute-stream": "0.0.8", - "ora": "^5.4.1", - "run-async": "^2.4.0", - "rxjs": "^7.5.5", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0", - "through": "^2.3.6", - "wrap-ansi": "^6.0.1" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-core-module": { - "version": "2.16.2", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", - "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-generator-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", - "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-interactive": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", - "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", - "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", - "license": "MIT", - "dependencies": { - "which-typed-array": "^1.1.16" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-unicode-supported": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", - "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-instrument": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", - "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@babel/core": "^7.23.9", - "@babel/parser": "^7.23.9", - "@istanbuljs/schema": "^0.1.3", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^7.5.4" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-source-maps": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", - "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0", - "source-map": "^0.6.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-source-maps/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/istanbul-reports": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/iterare": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/iterare/-/iterare-1.2.1.tgz", - "integrity": "sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==", - "license": "ISC", - "engines": { - "node": ">=6" - } - }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", - "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/core": "^29.7.0", - "@jest/types": "^29.6.3", - "import-local": "^3.0.2", - "jest-cli": "^29.7.0" - }, - "bin": { - "jest": "bin/jest.js" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/jest-changed-files": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", - "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", - "dev": true, - "license": "MIT", - "dependencies": { - "execa": "^5.0.0", - "jest-util": "^29.7.0", - "p-limit": "^3.1.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-changed-files/node_modules/jest-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-changed-files/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/jest-circus": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", - "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/expect": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "co": "^4.6.0", - "dedent": "^1.0.0", - "is-generator-fn": "^2.0.0", - "jest-each": "^29.7.0", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", - "p-limit": "^3.1.0", - "pretty-format": "^29.7.0", - "pure-rand": "^6.0.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-circus/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-circus/node_modules/jest-diff": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", - "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.0.0", - "diff-sequences": "^29.6.3", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-circus/node_modules/jest-matcher-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", - "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.0.0", - "jest-diff": "^29.7.0", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-circus/node_modules/jest-message-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", - "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.12.13", - "@jest/types": "^29.6.3", - "@types/stack-utils": "^2.0.0", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-circus/node_modules/jest-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-circus/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/jest-circus/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-cli": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", - "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/core": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "create-jest": "^29.7.0", - "exit": "^0.1.2", - "import-local": "^3.0.2", - "jest-config": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "yargs": "^17.3.1" - }, - "bin": { - "jest": "bin/jest.js" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/jest-cli/node_modules/jest-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-cli/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/jest-config": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", - "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.11.6", - "@jest/test-sequencer": "^29.7.0", - "@jest/types": "^29.6.3", - "babel-jest": "^29.7.0", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "deepmerge": "^4.2.2", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-circus": "^29.7.0", - "jest-environment-node": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-runner": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "micromatch": "^4.0.4", - "parse-json": "^5.2.0", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@types/node": "*", - "ts-node": ">=9.0.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "ts-node": { - "optional": true - } - } - }, - "node_modules/jest-config/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-config/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/jest-config/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/jest-config/node_modules/jest-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-config/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/jest-config/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/jest-config/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-diff": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.4.1.tgz", - "integrity": "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/diff-sequences": "30.4.0", - "@jest/get-type": "30.1.0", - "chalk": "^4.1.2", - "pretty-format": "30.4.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-docblock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", - "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "detect-newline": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-each": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", - "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", - "jest-util": "^29.7.0", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-each/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-each/node_modules/jest-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-each/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/jest-each/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-environment-node": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", - "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-environment-node/node_modules/jest-mock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", - "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-environment-node/node_modules/jest-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-environment-node/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/jest-get-type": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", - "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-haste-map": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", - "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/graceful-fs": "^4.1.3", - "@types/node": "*", - "anymatch": "^3.0.3", - "fb-watchman": "^2.0.0", - "graceful-fs": "^4.2.9", - "jest-regex-util": "^29.6.3", - "jest-util": "^29.7.0", - "jest-worker": "^29.7.0", - "micromatch": "^4.0.4", - "walker": "^1.0.8" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "optionalDependencies": { - "fsevents": "^2.3.2" - } - }, - "node_modules/jest-haste-map/node_modules/jest-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-haste-map/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/jest-leak-detector": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", - "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", - "dev": true, - "license": "MIT", - "dependencies": { - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-leak-detector/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-leak-detector/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-matcher-utils": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.4.1.tgz", - "integrity": "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/get-type": "30.1.0", - "chalk": "^4.1.2", - "jest-diff": "30.4.1", - "pretty-format": "30.4.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-message-util": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz", - "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@jest/types": "30.4.1", - "@types/stack-utils": "^2.0.3", - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", - "jest-util": "30.4.1", - "picomatch": "^4.0.3", - "pretty-format": "30.4.1", - "slash": "^3.0.0", - "stack-utils": "^2.0.6" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-message-util/node_modules/@jest/schemas": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", - "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sinclair/typebox": "^0.34.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-message-util/node_modules/@jest/types": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", - "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/pattern": "30.4.0", - "@jest/schemas": "30.4.1", - "@types/istanbul-lib-coverage": "^2.0.6", - "@types/istanbul-reports": "^3.0.4", - "@types/node": "*", - "@types/yargs": "^17.0.33", - "chalk": "^4.1.2" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-message-util/node_modules/@sinclair/typebox": { - "version": "0.34.52", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", - "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", - "dev": true, - "license": "MIT" - }, - "node_modules/jest-message-util/node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/jest-mock": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.4.1.tgz", - "integrity": "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "30.4.1", - "@types/node": "*", - "jest-util": "30.4.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-mock/node_modules/@jest/schemas": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", - "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sinclair/typebox": "^0.34.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-mock/node_modules/@jest/types": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", - "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/pattern": "30.4.0", - "@jest/schemas": "30.4.1", - "@types/istanbul-lib-coverage": "^2.0.6", - "@types/istanbul-reports": "^3.0.4", - "@types/node": "*", - "@types/yargs": "^17.0.33", - "chalk": "^4.1.2" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-mock/node_modules/@sinclair/typebox": { - "version": "0.34.52", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", - "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", - "dev": true, - "license": "MIT" - }, - "node_modules/jest-pnp-resolver": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", - "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "peerDependencies": { - "jest-resolve": "*" - }, - "peerDependenciesMeta": { - "jest-resolve": { - "optional": true - } - } - }, - "node_modules/jest-regex-util": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", - "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-resolve": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", - "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-pnp-resolver": "^1.2.2", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "resolve": "^1.20.0", - "resolve.exports": "^2.0.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-resolve-dependencies": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", - "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", - "dev": true, - "license": "MIT", - "dependencies": { - "jest-regex-util": "^29.6.3", - "jest-snapshot": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-resolve/node_modules/jest-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-resolve/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/jest-runner": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", - "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/console": "^29.7.0", - "@jest/environment": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "emittery": "^0.13.1", - "graceful-fs": "^4.2.9", - "jest-docblock": "^29.7.0", - "jest-environment-node": "^29.7.0", - "jest-haste-map": "^29.7.0", - "jest-leak-detector": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-resolve": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-util": "^29.7.0", - "jest-watcher": "^29.7.0", - "jest-worker": "^29.7.0", - "p-limit": "^3.1.0", - "source-map-support": "0.5.13" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-runner/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-runner/node_modules/jest-message-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", - "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.12.13", - "@jest/types": "^29.6.3", - "@types/stack-utils": "^2.0.0", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-runner/node_modules/jest-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-runner/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/jest-runner/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-runtime": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", - "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/globals": "^29.7.0", - "@jest/source-map": "^29.6.3", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "cjs-module-lexer": "^1.0.0", - "collect-v8-coverage": "^1.0.0", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-mock": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", - "slash": "^3.0.0", - "strip-bom": "^4.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-runtime/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-runtime/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/jest-runtime/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/jest-runtime/node_modules/jest-message-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", - "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.12.13", - "@jest/types": "^29.6.3", - "@types/stack-utils": "^2.0.0", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-runtime/node_modules/jest-mock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", - "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-runtime/node_modules/jest-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-runtime/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/jest-runtime/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/jest-runtime/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-snapshot": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", - "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.11.6", - "@babel/generator": "^7.7.2", - "@babel/plugin-syntax-jsx": "^7.7.2", - "@babel/plugin-syntax-typescript": "^7.7.2", - "@babel/types": "^7.3.3", - "@jest/expect-utils": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "babel-preset-current-node-syntax": "^1.0.0", - "chalk": "^4.0.0", - "expect": "^29.7.0", - "graceful-fs": "^4.2.9", - "jest-diff": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "natural-compare": "^1.4.0", - "pretty-format": "^29.7.0", - "semver": "^7.5.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-snapshot/node_modules/@jest/expect-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", - "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "jest-get-type": "^29.6.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-snapshot/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-snapshot/node_modules/expect": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/expect-utils": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-snapshot/node_modules/jest-diff": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", - "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.0.0", - "diff-sequences": "^29.6.3", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-snapshot/node_modules/jest-matcher-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", - "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.0.0", - "jest-diff": "^29.7.0", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-snapshot/node_modules/jest-message-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", - "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.12.13", - "@jest/types": "^29.6.3", - "@types/stack-utils": "^2.0.0", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-snapshot/node_modules/jest-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-snapshot/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/jest-snapshot/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-util": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", - "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "30.4.1", - "@types/node": "*", - "chalk": "^4.1.2", - "ci-info": "^4.2.0", - "graceful-fs": "^4.2.11", - "picomatch": "^4.0.3" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-util/node_modules/@jest/schemas": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", - "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sinclair/typebox": "^0.34.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-util/node_modules/@jest/types": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", - "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/pattern": "30.4.0", - "@jest/schemas": "30.4.1", - "@types/istanbul-lib-coverage": "^2.0.6", - "@types/istanbul-reports": "^3.0.4", - "@types/node": "*", - "@types/yargs": "^17.0.33", - "chalk": "^4.1.2" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-util/node_modules/@sinclair/typebox": { - "version": "0.34.52", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", - "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", - "dev": true, - "license": "MIT" - }, - "node_modules/jest-util/node_modules/ci-info": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", - "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-util/node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/jest-validate": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", - "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "camelcase": "^6.2.0", - "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", - "leven": "^3.1.0", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-validate/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-validate/node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/jest-validate/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-watcher": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", - "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", - "emittery": "^0.13.1", - "jest-util": "^29.7.0", - "string-length": "^4.0.1" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-watcher/node_modules/jest-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-watcher/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/jest-worker": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", - "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "jest-util": "^29.7.0", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-worker/node_modules/jest-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-worker/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/jest-worker/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/joi": { - "version": "18.2.5", - "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.5.tgz", - "integrity": "sha512-+gEA7rLfaNWx9JzawWPrPetSZwT16NUqHtECDgjyAJreXcs4TM7tx2Pa+VVJJK0YHM83ybrVdaT6UekHH50FJQ==", - "license": "BSD-3-Clause", - "dependencies": { - "@hapi/address": "^5.1.1", - "@hapi/formula": "^3.0.2", - "@hapi/hoek": "^11.0.7", - "@hapi/pinpoint": "^2.0.1", - "@hapi/tlds": "^1.1.1", - "@hapi/topo": "^6.0.2", - "@standard-schema/spec": "^1.1.0" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/joi-to-typescript": { - "version": "4.15.0", - "resolved": "https://registry.npmjs.org/joi-to-typescript/-/joi-to-typescript-4.15.0.tgz", - "integrity": "sha512-GT8+Q4OK2XGkhu8lFhHr94j8YByTBxPnYBXFmo3mAgVQNYxCG+2WLK92YqBpN9xwSatheyVu0SZvviI2SvXSSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "url": "https://github.com/mrjono1/joi-to-typescript?sponsor=1" - }, - "peerDependencies": { - "joi": "17.x" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/jsonc-parser": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.2.1.tgz", - "integrity": "sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==", - "dev": true, - "license": "MIT" - }, - "node_modules/jsonfile": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", - "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/jsonwebtoken": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", - "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", - "license": "MIT", - "dependencies": { - "jws": "^3.2.2", - "lodash.includes": "^4.3.0", - "lodash.isboolean": "^3.0.3", - "lodash.isinteger": "^4.0.4", - "lodash.isnumber": "^3.0.3", - "lodash.isplainobject": "^4.0.6", - "lodash.isstring": "^4.0.1", - "lodash.once": "^4.0.0", - "ms": "^2.1.1", - "semver": "^7.5.4" - }, - "engines": { - "node": ">=12", - "npm": ">=6" - } - }, - "node_modules/jwa": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", - "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", - "license": "MIT", - "dependencies": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/jws": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz", - "integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==", - "license": "MIT", - "dependencies": { - "jwa": "^1.4.2", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/kleur": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", - "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/kuler": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", - "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", - "license": "MIT" - }, - "node_modules/leven": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", - "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/libphonenumber-js": { - "version": "1.13.9", - "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.9.tgz", - "integrity": "sha512-VNS5vWMM7r0P66BYv+TQJATxExEgLxN+34hfHDVhDkUsGAE4cRg0shCNSLTXNKm7nIUscC7AfB51TjxEeF7msQ==", - "license": "MIT" - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, - "license": "MIT" - }, - "node_modules/loader-runner": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", - "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.11.5" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "license": "MIT" - }, - "node_modules/lodash.includes": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", - "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", - "license": "MIT" - }, - "node_modules/lodash.isboolean": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", - "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", - "license": "MIT" - }, - "node_modules/lodash.isinteger": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", - "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", - "license": "MIT" - }, - "node_modules/lodash.isnumber": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", - "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", - "license": "MIT" - }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "license": "MIT" - }, - "node_modules/lodash.isstring": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", - "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", - "license": "MIT" - }, - "node_modules/lodash.memoize": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", - "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.once": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", - "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", - "license": "MIT" - }, - "node_modules/log-symbols": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", - "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.0", - "is-unicode-supported": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/logform": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz", - "integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==", - "license": "MIT", - "dependencies": { - "@colors/colors": "1.6.0", - "@types/triple-beam": "^1.3.2", - "fecha": "^4.2.0", - "ms": "^2.1.1", - "safe-stable-stringify": "^2.3.1", - "triple-beam": "^1.3.0" - }, - "engines": { - "node": ">= 12.0.0" - } - }, - "node_modules/logform/node_modules/@colors/colors": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", - "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", - "license": "MIT", - "engines": { - "node": ">=0.1.90" - } - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/magic-string": { - "version": "0.30.8", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz", - "integrity": "sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true, - "license": "ISC" - }, - "node_modules/makeerror": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", - "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tmpl": "1.0.5" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/memfs": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.5.3.tgz", - "integrity": "sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==", - "dev": true, - "license": "Unlicense", - "dependencies": { - "fs-monkey": "^1.0.4" - }, - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true, - "license": "MIT" - }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "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", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "license": "MIT", - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/multer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", - "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", - "license": "MIT", - "dependencies": { - "append-field": "^1.0.0", - "busboy": "^1.6.0", - "concat-stream": "^2.0.0", - "type-is": "^1.6.18" - }, - "engines": { - "node": ">= 10.16.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/mute-stream": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", - "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", - "dev": true, - "license": "ISC" - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/neo-async": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "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==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-emoji": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz", - "integrity": "sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==", - "dev": true, - "license": "MIT", - "dependencies": { - "lodash": "^4.17.21" - } - }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/node-int64": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", - "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-releases": { - "version": "2.0.51", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", - "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/one-time": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz", - "integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==", - "license": "MIT", - "dependencies": { - "fn.name": "1.x.x" - } - }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/ora": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", - "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "bl": "^4.1.0", - "chalk": "^4.1.0", - "cli-cursor": "^3.1.0", - "cli-spinners": "^2.5.0", - "is-interactive": "^1.0.0", - "is-unicode-supported": "^0.1.0", - "log-symbols": "^4.1.0", - "strip-ansi": "^6.0.0", - "wcwidth": "^1.0.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/os-tmpdir": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", - "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "license": "BlueOak-1.0.0" - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, - "license": "MIT" - }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "license": "ISC" - }, - "node_modules/path-to-regexp": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.3.0.tgz", - "integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==", - "license": "MIT" - }, - "node_modules/path-type": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", - "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/peek-readable": { - "version": "5.4.2", - "resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-5.4.2.tgz", - "integrity": "sha512-peBp3qZyuS6cNIJ2akRNG1uo1WJ1d0wTxg/fxMdZ0BqCVhx242bSFHM9eNqflfJVS9SsgkzgT/1UgnsurBOTMg==", - "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.1.tgz", - "integrity": "sha512-xUXwsxNjwTQ8K3GnT4pCJm+xq3RUPQbmkYJTP5aFIfNIvbcc/4MUxgBaaRSZJ6yGJZiGSyYlM6MzwTsRk8SYCg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pirates": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", - "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/pkg-dir": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", - "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "find-up": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-dir/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-dir/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-dir/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pkg-dir/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pluralize": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", - "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/possible-typed-array-names": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", - "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/pretty-format": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", - "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "30.4.1", - "ansi-styles": "^5.2.0", - "react-is-18": "npm:react-is@^18.3.1", - "react-is-19": "npm:react-is@^19.2.5" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/pretty-format/node_modules/@jest/schemas": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", - "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sinclair/typebox": "^0.34.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/pretty-format/node_modules/@sinclair/typebox": { - "version": "0.34.52", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", - "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", - "dev": true, - "license": "MIT" - }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/prom-client": { - "version": "15.1.3", - "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz", - "integrity": "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==", - "deprecated": "prom-client has been replaced by @prometheus-io/client", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.4.0", - "tdigest": "^0.1.1" - }, - "engines": { - "node": "^16 || ^18 || >=20" - } - }, - "node_modules/prompts": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", - "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "kleur": "^3.0.3", - "sisteransi": "^1.0.5" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/proxy-from-env": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", - "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/pure-rand": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", - "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ], - "license": "MIT" - }, - "node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, - "node_modules/react-is-18": { - "name": "react-is", - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, - "node_modules/react-is-19": { - "name": "react-is", - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", - "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/readdirp/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/reflect-metadata": { - "version": "0.1.14", - "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.14.tgz", - "integrity": "sha512-ZhYeb6nRaXCfhnndflDK8qI6ZQ/YcWZCISRAWICW9XYqMUwjZM9Z0DveWX/ABN01oxSHwVxKQmxeYZSsm0jh5A==", - "license": "Apache-2.0" - }, - "node_modules/repeat-string": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", - "integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10" - } - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/resolve": { - "version": "1.22.12", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", - "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-cwd": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", - "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/resolve-cwd/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/resolve.exports": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", - "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/restore-cursor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", - "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/restore-cursor/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/rimraf/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/run-async": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", - "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/rxjs": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safe-stable-stringify": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", - "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/schema-utils": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", - "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.8", - "ajv": "^6.12.5", - "ajv-keywords": "^3.5.2" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/schema-utils/node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/schema-utils/node_modules/ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "ajv": "^6.9.1" - } - }, - "node_modules/schema-utils/node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/send": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", - "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.1", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "~2.4.1", - "range-parser": "~1.2.1", - "statuses": "~2.0.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/send/node_modules/debug/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/serve-static": { - "version": "1.16.3", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", - "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", - "license": "MIT", - "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "~0.19.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, - "node_modules/sha.js": { - "version": "2.4.12", - "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", - "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", - "license": "(MIT AND BSD-3-Clause)", - "dependencies": { - "inherits": "^2.0.4", - "safe-buffer": "^5.2.1", - "to-buffer": "^1.2.0" - }, - "bin": { - "sha.js": "bin.js" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/side-channel": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", - "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4", - "side-channel-list": "^1.0.1", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", - "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/sisteransi": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "dev": true, - "license": "MIT" - }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/source-map": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", - "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">= 8" - } - }, - "node_modules/source-map-support": { - "version": "0.5.13", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", - "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/source-map-support/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/sql-highlight": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/sql-highlight/-/sql-highlight-6.1.0.tgz", - "integrity": "sha512-ed7OK4e9ywpE7pgRMkMQmZDPKSVdm0oX5IEtZiKnFucSF0zu6c80GZBe38UqHuVhTWJ9xsKgSMjCG2bml86KvA==", - "funding": [ - "https://github.com/scriptcoded/sql-highlight?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/scriptcoded" - } - ], - "license": "MIT", - "engines": { - "node": ">=14" - } - }, - "node_modules/stack-trace": { - "version": "0.0.10", - "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", - "integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==", - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/stack-utils": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", - "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "escape-string-regexp": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/stack-utils/node_modules/escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/streamsearch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", - "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/string_decoder/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, - "node_modules/string-length": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", - "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "char-regex": "^1.0.2", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-bom": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", - "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/strtok3": { - "version": "9.1.1", - "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-9.1.1.tgz", - "integrity": "sha512-FhwotcEqjr241ZbjFzjlIYg6c5/L/s4yBGWSMvJ9UoExiSqL+FnFA/CaeZx17WGaZMS/4SOZp8wH18jSS4R4lw==", - "license": "MIT", - "dependencies": { - "@tokenizer/token": "^0.3.0", - "peek-readable": "^5.3.1" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/swagger-ui-dist": { - "version": "5.17.14", - "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.17.14.tgz", - "integrity": "sha512-CVbSfaLpstV65OnSjbXfVd6Sta3q3F7Cj/yYuvHMp1P90LztOLs6PfUnKEVAeiIVQt9u2SaPwv0LiH/OyMjHRw==", - "license": "Apache-2.0" - }, - "node_modules/symbol-observable": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", - "integrity": "sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10" - } - }, - "node_modules/tapable": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", - "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/tdigest": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.3.tgz", - "integrity": "sha512-zbRt+lT+/H4fRItHshczHErVCQnitJk8MfMT24MqFJf3YL7SJJPqGIGeuOdvxXxM/AHFzKBl7WoyaYwqO9s3Kw==", - "license": "MIT", - "dependencies": { - "bintrees": "1.0.2" - } - }, - "node_modules/terser": { - "version": "5.49.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.49.0.tgz", - "integrity": "sha512-SNiDnXyHSrxVcIOtVbULzcTmniUiwcV7Nwdyj1twVubeTmbjoa8p69KKDpfkdoOavuM4/GRm1+ykI8qqnavHoA==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.15.0", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "bin": { - "terser": "bin/terser" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/terser-webpack-plugin": { - "version": "5.6.1", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.6.1.tgz", - "integrity": "sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.25", - "jest-worker": "^27.4.5", - "schema-utils": "^4.3.0", - "terser": "^5.31.1" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.1.0" - }, - "peerDependenciesMeta": { - "@minify-html/node": { - "optional": true - }, - "@swc/core": { - "optional": true - }, - "@swc/css": { - "optional": true - }, - "@swc/html": { - "optional": true - }, - "clean-css": { - "optional": true - }, - "cssnano": { - "optional": true - }, - "csso": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "html-minifier-terser": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "postcss": { - "optional": true - }, - "uglify-js": { - "optional": true - } - } - }, - "node_modules/terser-webpack-plugin/node_modules/jest-worker": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", - "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - }, - "engines": { - "node": ">= 10.13.0" - } - }, - "node_modules/terser-webpack-plugin/node_modules/schema-utils": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", - "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/terser-webpack-plugin/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/terser/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/terser/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/terser/node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/test-exclude": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", - "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", - "dev": true, - "license": "ISC", - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^7.1.4", - "minimatch": "^3.0.4" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/test-exclude/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/test-exclude/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/text-hex": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", - "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", - "license": "MIT" - }, - "node_modules/text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true, - "license": "MIT" - }, - "node_modules/through": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", - "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "dev": true, - "license": "MIT", - "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", - "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "os-tmpdir": "~1.0.2" - }, - "engines": { - "node": ">=0.6.0" - } - }, - "node_modules/tmpl": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", - "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/to-buffer": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", - "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", - "license": "MIT", - "dependencies": { - "isarray": "^2.0.5", - "safe-buffer": "^5.2.1", - "typed-array-buffer": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/to-buffer/node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "license": "MIT" - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/token-types": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", - "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", - "license": "MIT", - "dependencies": { - "@borewit/text-codec": "^0.2.1", - "@tokenizer/token": "^0.3.0", - "ieee754": "^1.2.1" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, - "node_modules/tree-kill": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", - "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", - "dev": true, - "license": "MIT", - "bin": { - "tree-kill": "cli.js" - } - }, - "node_modules/triple-beam": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", - "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", - "license": "MIT", - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/ts-jest": { - "version": "29.4.12", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.12.tgz", - "integrity": "sha512-Ov6ClY53Fflh6BGAnY2DlTq1hYDrTycz2PVTXBWFW2CU+9zrEqAp9fWdGXl42EXO5RLSFAcAZ2JFKbP+zBTFfw==", - "dev": true, - "license": "MIT", - "dependencies": { - "bs-logger": "^0.2.6", - "fast-json-stable-stringify": "^2.1.0", - "handlebars": "^4.7.9", - "json5": "^2.2.3", - "lodash.memoize": "^4.1.2", - "make-error": "^1.3.6", - "semver": "^7.8.5", - "type-fest": "^4.41.0", - "yargs-parser": "^21.1.1" - }, - "bin": { - "ts-jest": "cli.js" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" - }, - "peerDependencies": { - "@babel/core": ">=7.0.0-beta.0 <8", - "@jest/transform": "^29.0.0 || ^30.0.0", - "@jest/types": "^29.0.0 || ^30.0.0", - "babel-jest": "^29.0.0 || ^30.0.0", - "jest": "^29.0.0 || ^30.0.0", - "jest-util": "^29.0.0 || ^30.0.0", - "typescript": ">=4.3 <7" - }, - "peerDependenciesMeta": { - "@babel/core": { - "optional": true - }, - "@jest/transform": { - "optional": true - }, - "@jest/types": { - "optional": true - }, - "babel-jest": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jest-util": { - "optional": true - } - } - }, - "node_modules/ts-jest/node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ts-node": { - "version": "10.9.2", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", - "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@cspotcode/source-map-support": "^0.8.0", - "@tsconfig/node10": "^1.0.7", - "@tsconfig/node12": "^1.0.7", - "@tsconfig/node14": "^1.0.0", - "@tsconfig/node16": "^1.0.2", - "acorn": "^8.4.1", - "acorn-walk": "^8.1.1", - "arg": "^4.1.0", - "create-require": "^1.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "v8-compile-cache-lib": "^3.0.1", - "yn": "3.1.1" - }, - "bin": { - "ts-node": "dist/bin.js", - "ts-node-cwd": "dist/bin-cwd.js", - "ts-node-esm": "dist/bin-esm.js", - "ts-node-script": "dist/bin-script.js", - "ts-node-transpile-only": "dist/bin-transpile.js", - "ts-script": "dist/bin-script-deprecated.js" - }, - "peerDependencies": { - "@swc/core": ">=1.2.50", - "@swc/wasm": ">=1.2.50", - "@types/node": "*", - "typescript": ">=2.7" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "@swc/wasm": { - "optional": true - } - } - }, - "node_modules/tsconfig-paths": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", - "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", - "dev": true, - "license": "MIT", - "dependencies": { - "json5": "^2.2.2", - "minimist": "^1.2.6", - "strip-bom": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/tsconfig-paths-webpack-plugin": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/tsconfig-paths-webpack-plugin/-/tsconfig-paths-webpack-plugin-4.2.0.tgz", - "integrity": "sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.0", - "enhanced-resolve": "^5.7.0", - "tapable": "^2.2.1", - "tsconfig-paths": "^4.1.2" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/tsconfig-paths/node_modules/strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/type-detect": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", - "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "license": "MIT", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/typed-array-buffer": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", - "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-typed-array": "^1.1.14" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/typedarray": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", - "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", - "license": "MIT" - }, - "node_modules/typeorm": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.31.tgz", - "integrity": "sha512-6u9EFtdLBgHjnPm78NStVeM+I/1MolTzKykDDcydzKUkh6E++YS6XViU/fePJbvDvEGU4Xq34KOM/CLeer9I2A==", - "license": "MIT", - "dependencies": { - "@sqltools/formatter": "^1.2.5", - "ansis": "^4.3.1", - "app-root-path": "^3.1.0", - "buffer": "^6.0.3", - "dayjs": "^1.11.21", - "debug": "^4.4.3", - "dedent": "^1.7.2", - "dotenv": "^16.6.1", - "glob": "^10.5.0", - "reflect-metadata": "^0.2.2", - "sha.js": "^2.4.12", - "sql-highlight": "^6.1.0", - "tslib": "^2.8.1", - "uuid": "^11.1.1", - "yargs": "^17.7.3" - }, - "bin": { - "typeorm": "cli.js", - "typeorm-ts-node-commonjs": "cli-ts-node-commonjs.js", - "typeorm-ts-node-esm": "cli-ts-node-esm.js" - }, - "engines": { - "node": ">=16.13.0" - }, - "funding": { - "url": "https://opencollective.com/typeorm" - }, - "peerDependencies": { - "@google-cloud/spanner": "^5.18.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", - "@sap/hana-client": "^2.14.22", - "better-sqlite3": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0", - "ioredis": "^5.0.4", - "mongodb": "^5.8.0 || ^6.0.0", - "mssql": "^9.1.1 || ^10.0.0 || ^11.0.0 || ^12.0.0", - "mysql2": "^2.2.5 || ^3.0.1", - "oracledb": "^6.3.0 || ^7.0.0", - "pg": "^8.5.1", - "pg-native": "^3.0.0", - "pg-query-stream": "^4.0.0", - "redis": "^3.1.1 || ^4.0.0 || ^5.0.14", - "sql.js": "^1.4.0", - "sqlite3": "^5.0.3 || ^6.0.0", - "ts-node": "^10.7.0", - "typeorm-aurora-data-api-driver": "^2.0.0 || ^3.0.0" - }, - "peerDependenciesMeta": { - "@google-cloud/spanner": { - "optional": true - }, - "@sap/hana-client": { - "optional": true - }, - "better-sqlite3": { - "optional": true - }, - "ioredis": { - "optional": true - }, - "mongodb": { - "optional": true - }, - "mssql": { - "optional": true - }, - "mysql2": { - "optional": true - }, - "oracledb": { - "optional": true - }, - "pg": { - "optional": true - }, - "pg-native": { - "optional": true - }, - "pg-query-stream": { - "optional": true - }, - "redis": { - "optional": true - }, - "sql.js": { - "optional": true - }, - "sqlite3": { - "optional": true - }, - "ts-node": { - "optional": true - }, - "typeorm-aurora-data-api-driver": { - "optional": true - } - } - }, - "node_modules/typeorm/node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, - "node_modules/typeorm/node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/typeorm/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/typeorm/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/typeorm/node_modules/reflect-metadata": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", - "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "license": "Apache-2.0" - }, - "node_modules/typeorm/node_modules/uuid": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", - "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/esm/bin/uuid" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/typescript-eslint": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.65.0.tgz", - "integrity": "sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/eslint-plugin": "8.65.0", - "@typescript-eslint/parser": "8.65.0", - "@typescript-eslint/typescript-estree": "8.65.0", - "@typescript-eslint/utils": "8.65.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", - "integrity": "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.65.0", - "@typescript-eslint/type-utils": "8.65.0", - "@typescript-eslint/utils": "8.65.0", - "@typescript-eslint/visitor-keys": "8.65.0", - "ignore": "^7.0.5", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.65.0", - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/parser": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.65.0.tgz", - "integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/scope-manager": "8.65.0", - "@typescript-eslint/types": "8.65.0", - "@typescript-eslint/typescript-estree": "8.65.0", - "@typescript-eslint/visitor-keys": "8.65.0", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/scope-manager": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.65.0.tgz", - "integrity": "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.65.0", - "@typescript-eslint/visitor-keys": "8.65.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/type-utils": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.65.0.tgz", - "integrity": "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.65.0", - "@typescript-eslint/typescript-estree": "8.65.0", - "@typescript-eslint/utils": "8.65.0", - "debug": "^4.4.3", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/types": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.65.0.tgz", - "integrity": "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/typescript-estree": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.65.0.tgz", - "integrity": "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.65.0", - "@typescript-eslint/tsconfig-utils": "8.65.0", - "@typescript-eslint/types": "8.65.0", - "@typescript-eslint/visitor-keys": "8.65.0", - "debug": "^4.4.3", - "minimatch": "^10.2.2", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/utils": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.65.0.tgz", - "integrity": "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.65.0", - "@typescript-eslint/types": "8.65.0", - "@typescript-eslint/typescript-estree": "8.65.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/typescript-eslint/node_modules/@typescript-eslint/visitor-keys": { - "version": "8.65.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.65.0.tgz", - "integrity": "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.65.0", - "eslint-visitor-keys": "^5.0.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/typescript-eslint/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/typescript-eslint/node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/typescript-eslint/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/typescript-eslint/node_modules/ignore": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", - "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/typescript-eslint/node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.8" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/typescript-eslint/node_modules/ts-api-utils": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", - "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, - "node_modules/uglify-js": { - "version": "3.19.3", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", - "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "bin": { - "uglifyjs": "bin/uglifyjs" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/uid": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/uid/-/uid-2.0.2.tgz", - "integrity": "sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==", - "license": "MIT", - "dependencies": { - "@lukeed/csprng": "^1.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/uint8array-extras": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", - "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "license": "MIT" - }, - "node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/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", - "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "dev": true, - "license": "MIT" - }, - "node_modules/v8-to-istanbul": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", - "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", - "dev": true, - "license": "ISC", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.12", - "@types/istanbul-lib-coverage": "^2.0.1", - "convert-source-map": "^2.0.0" - }, - "engines": { - "node": ">=10.12.0" - } - }, - "node_modules/validator": { - "version": "13.15.35", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz", - "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/walker": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", - "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "makeerror": "1.0.12" - } - }, - "node_modules/watchpack": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz", - "integrity": "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.1.2" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/wcwidth": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", - "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", - "dev": true, - "license": "MIT", - "dependencies": { - "defaults": "^1.0.3" - } - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/webpack": { - "version": "5.97.1", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.97.1.tgz", - "integrity": "sha512-EksG6gFY3L1eFMROS/7Wzgrii5mBAFe4rIr3r2BTfo7bcc+DWwFZ4OJ/miOuHJO/A85HwyI4eQ0F6IKXesO7Fg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/eslint-scope": "^3.7.7", - "@types/estree": "^1.0.6", - "@webassemblyjs/ast": "^1.14.1", - "@webassemblyjs/wasm-edit": "^1.14.1", - "@webassemblyjs/wasm-parser": "^1.14.1", - "acorn": "^8.14.0", - "browserslist": "^4.24.0", - "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.1", - "es-module-lexer": "^1.2.1", - "eslint-scope": "5.1.1", - "events": "^3.2.0", - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.2.11", - "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.2.0", - "mime-types": "^2.1.27", - "neo-async": "^2.6.2", - "schema-utils": "^3.2.0", - "tapable": "^2.1.1", - "terser-webpack-plugin": "^5.3.10", - "watchpack": "^2.4.1", - "webpack-sources": "^3.2.3" - }, - "bin": { - "webpack": "bin/webpack.js" - }, - "engines": { - "node": ">=10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependenciesMeta": { - "webpack-cli": { - "optional": true - } - } - }, - "node_modules/webpack-node-externals": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/webpack-node-externals/-/webpack-node-externals-3.0.0.tgz", - "integrity": "sha512-LnL6Z3GGDPht/AigwRh2dvL9PQPFQ8skEpVrWZXLWBYmqcaojHNN0onvHzie6rq7EWKrrBfPYqNEzTJgiwEQDQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/webpack-sources": { - "version": "3.5.1", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.1.tgz", - "integrity": "sha512-jyuiGJdtvY434z5bUZrjz67v76/ePNvFZTp9Mdz29IlH4+GPsgyGjiv0fKI+M7BdkU6ADjulUcKAd3tUK3WlEw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/webpack/node_modules/eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/webpack/node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/which-typed-array": { - "version": "1.1.22", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", - "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.9", - "call-bound": "^1.0.4", - "for-each": "^0.3.5", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/winston": { - "version": "3.19.0", - "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", - "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", - "license": "MIT", - "dependencies": { - "@colors/colors": "^1.6.0", - "@dabh/diagnostics": "^2.0.8", - "async": "^3.2.3", - "is-stream": "^2.0.0", - "logform": "^2.7.0", - "one-time": "^1.0.0", - "readable-stream": "^3.4.0", - "safe-stable-stringify": "^2.3.1", - "stack-trace": "0.0.x", - "triple-beam": "^1.3.0", - "winston-transport": "^4.9.0" - }, - "engines": { - "node": ">= 12.0.0" - } - }, - "node_modules/winston-transport": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz", - "integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==", - "license": "MIT", - "dependencies": { - "logform": "^2.7.0", - "readable-stream": "^3.6.2", - "triple-beam": "^1.3.0" - }, - "engines": { - "node": ">= 12.0.0" - } - }, - "node_modules/winston/node_modules/@colors/colors": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", - "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", - "license": "MIT", - "engines": { - "node": ">=0.1.90" - } - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/wordwrap": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", - "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/write-file-atomic": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", - "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", - "dev": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4", - "signal-exit": "^3.0.7" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/write-file-atomic/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "license": "MIT", - "engines": { - "node": ">=0.4" - } - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" - }, - "node_modules/yargs": { - "version": "17.7.3", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", - "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/yn": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", - "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - } - } -} diff --git a/BackendAcademy/package.json b/BackendAcademy/package.json index 256cbb43ce..5d0e730b2f 100644 --- a/BackendAcademy/package.json +++ b/BackendAcademy/package.json @@ -1,54 +1,67 @@ { - "name": "rustacademy-backend", + "name": "@rustacademy/backend-academy", "version": "0.1.0", "private": true, - "description": "RustAcademy NestJS backend", + "description": "RustAcademy BackendAcademy NestJS backend API", "scripts": { + "dev": "nest start --watch", + "start:dev": "nest start --watch", "build": "nest build", "start": "node dist/main", - "start:dev": "nest start --watch", "lint": "eslint \"{src,test}/**/*.ts\"", - "typecheck": "tsc --noEmit", - "test": "jest --config jest.config.ts", - "test:unit": "NODE_ENV=test jest --config jest.config.ts --runInBand --testPathPattern='\\.spec\\.ts$'", - "test:ci": "NODE_ENV=test jest --config jest.config.ts --runInBand" + "type-check": "tsc --noEmit", + "test": "jest", + "test:watch": "jest --watch", + "test:e2e": "jest --config ./test/jest-e2e.json" }, "dependencies": { "@nestjs/common": "^10.4.22", "@nestjs/config": "^3.3.0", "@nestjs/core": "^10.0.0", - "@nestjs/jwt": "^10.2.0", "@nestjs/platform-express": "^10.0.0", "@nestjs/swagger": "^7.4.2", "@nestjs/throttler": "^6.5.0", - "@nestjs/typeorm": "^10.0.2", - "@willsoto/nestjs-prometheus": "^6.1.0", + "@stellar/stellar-sdk": "^14.5.0", + "@supabase/supabase-js": "^2.0.0", "class-transformer": "^0.5.1", - "class-validator": "^0.14.4", - "file-type": "^19.6.0", - "joi": "^18.0.2", - "multer": "^2.2.0", - "prom-client": "^15.1.3", - "@willsoto/nestjs-prometheus": "^6.0.0", - "axios": "^1.7.9", + "class-validator": "^0.14.0", + "helmet": "^8.1.0", "reflect-metadata": "^0.1.13", - "rxjs": "^7.8.1", - "typeorm": "^0.3.30", - "winston": "^3.19.0" + "rxjs": "^7.8.1" }, "devDependencies": { "@nestjs/cli": "^10.0.0", + "@nestjs/schematics": "^10.0.0", "@nestjs/testing": "^10.0.0", - "@types/jest": "^30.0.0", - "@types/multer": "^1.4.12", + "@types/express": "^5.0.6", + "@types/jest": "^29.5.0", "@types/node": "^20.0.0", - "@types/uuid": "^10.0.0", - "eslint": "^8.0.0", + "@types/supertest": "^6.0.2", "jest": "^29.7.0", - "joi-to-typescript": "^4.2.0", + "supertest": "^6.3.3", "ts-jest": "^29.1.0", - "ts-node": "^10.9.0", - "typescript": "^5.2.2", - "typescript-eslint": "^8.0.0" + "typescript": "^5.2.2" + }, + "pnpm": { + "neverBuiltDependencies": [ + "unrs-resolver" + ] + }, + "jest": { + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], + "rootDir": "src", + "testRegex": ".*\\.spec\\.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "collectCoverageFrom": [ + "**/*.(t|j)s" + ], + "coverageDirectory": "../coverage", + "testEnvironment": "node" } } diff --git a/BackendAcademy/readme.md b/BackendAcademy/readme.md deleted file mode 100644 index 701157ba70..0000000000 --- a/BackendAcademy/readme.md +++ /dev/null @@ -1,256 +0,0 @@ -# BackendAcademy - -RustAcademy backend module — NestJS backend implementation. - -## Getting Started - -```bash -pnpm install -pnpm start:dev -``` - -The server starts on `http://localhost:3000` by default (configurable via `PORT`). - -## Environment Variables - -The following environment variables are validated at startup. Copy `.env.example` (or set them in your deployment config) and customize as needed. - -| Variable | Required (prod) | Default (dev/test) | Description | -| ---------------------- | ------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | -| `NODE_ENV` | No | `development` | Runtime environment (`development`, `production`, `test`) | -| `PORT` | No | `3000` | Server port | -| `CORS_ORIGIN` | No | `*` | Allowed CORS origin(s): `*` or a comma-separated list | -| `DATABASE_URL` | **Yes** | `postgresql://…/rustacademy_development` | Database connection string. Mandatory in production — the service refuses to boot without persistence configured | -| `REDIS_HOST` | **Yes** | `localhost` | Redis host for caching and background jobs. Mandatory in production | -| `REDIS_PORT` | No | `6379` | Redis port | -| `REDIS_PASSWORD` | No | — | Optional Redis password | -| `JWT_SECRET` | **Yes** | `development`-only insecure value | Secret key for JWT signing. Required in production and must be ≥ 32 chars; the shipped example/development values are rejected | -| `ASSET_SIGNING_SECRET` | **Yes** | `development`-only insecure value | HMAC secret for signed asset URLs. Required in production; an empty secret makes signed URLs forgeable | -| `AI_PROVIDER` | No | `mock` | AI provider (`claude`, `openai`, `mock`) | -| `ANTHROPIC_API_KEY` | When `AI_PROVIDER=claude` | — | Anthropic API key | -| `OPENAI_API_KEY` | When `AI_PROVIDER=openai` | — | OpenAI API key | -| `AI_MODEL` | No | — | AI model override | -| `AI_MAX_TOKENS` | No | `4096` | Max tokens for AI requests | -| `AI_TEMPERATURE` | No | `0.7` | Temperature for AI responses | -| `LOCALE` | No | `en` | Localization locale (currently `en` supported) | -| `ASSETS_UPLOAD_DIR` | No | `./data/uploads` | Directory where uploaded assets are persisted | -| `ASSETS_STATIC_DIR` | No | `./public` | Read-only static asset directory served at `/static` | -| `ASSETS_BASE_URL` | No | `/api/v1/assets` | Base URL advertised inside asset metadata | -| `ASSETS_MAX_SIZE_MB` | No | `10` | Maximum size of a single uploaded asset (MB) | -| `ASSETS_MAX_TOTAL_MB` | No | `1024` | Aggregate byte quota across all stored assets (MB) | -| `ASSETS_MAX_COUNT` | No | `10000` | Maximum number of assets retained by the registry | - -> **Production note:** when `NODE_ENV=production`, startup validation rejects missing -> `DATABASE_URL`, `REDIS_HOST`, `JWT_SECRET` and `ASSET_SIGNING_SECRET`, low-entropy -> secrets, and placeholder values copied from `.env.example`. Development and test -> environments keep explicit (non-production) defaults so local boots stay simple. -> Validation errors never include secret values. - -## Cron Scheduling - -Background jobs are configured via cron expressions in the standard 5-field format: - -``` -minute hour day-of-month month day-of-week -``` - -### Schedule Configuration - -| Variable | Default | Description | -| ----------------------------- | -------------- | ------------------------------ | -| `CRON_CLEANUP_SCHEDULE` | `0 0 * * *` | Daily cleanup at midnight | -| `CRON_ANALYTICS_SCHEDULE` | `0 */6 * * *` | Analytics every 6 hours | -| `CRON_NOTIFICATIONS_SCHEDULE` | `*/30 * * * *` | Notifications every 30 minutes | - -### Cron Expression Reference - -| Expression | Meaning | -| ------------- | ------------------------------------- | -| `* * * * *` | Every minute | -| `*/5 * * * *` | Every 5 minutes | -| `0 * * * *` | Every hour at minute 0 | -| `0 0 * * *` | At midnight daily | -| `0 0 * * 0` | At midnight every Sunday | -| `0 9 * * 1-5` | At 9 AM, Monday–Friday | -| `0 0 1 * *` | At midnight on the 1st of every month | - -### Field Ranges - -| Field | Allowed Values | -| ------------ | ------------------------ | -| Minute | `0–59` | -| Hour | `0–23` | -| Day of Month | `1–31` | -| Month | `1–12` | -| Day of Week | `0–7` (0 or 7 is Sunday) | - -Invalid cron expressions are caught at startup and logged as errors. Use the `/api/jobs/schedules` endpoint to view all configured schedules and their next run times. - -## Localization - -The backend ships with a localization service (`I18nModule`) that provides translated strings for admin and learner flows. - -- **Default locale:** `en` -- **Configurable via:** `LOCALE` environment variable -- **Available locales:** `en` - -The `LocalizationService` is global and injectable. Use `l10n.t('key')` to retrieve localized strings in any service/controller. - -### Adding a new locale - -Add a new entry to the `STRINGS` map in `src/i18n/localization.service.ts` with all required keys. - -## Structure - -- `src/` — Application source code (NestJS modules, controllers, services) -- `src/config/` — Configuration and environment validation -- `src/database/` — Database module, migration service with preflight validation and rollback -- `src/i18n/` — Localization module -- `src/jobs/` — Background job scheduling with cron validation and notification batching -- `src/monitoring/` — Metrics and observability -- `src/notifications/` — Notification delivery with provider abstraction (email, push, in-app) and batching -- `test/` — Test files - -## Validation - -- Environment variables are validated at startup using `src/config/env.schema.ts`. -- Localization fallback and translation key validation are covered by unit tests and CI via `pnpm test`. - -See `app/backend/` for the primary backend implementation and conventions. - -## Database Migrations (#398) - -The `MigrationService` provides preflight validation and rollback awareness for database migrations: - -### Migration Scripts - -| Script | Description | -| -------------------------- | ----------------------------------------------- | -| `pnpm migration:preflight` | Run preflight checks before applying migrations | -| `pnpm migration:rollback` | Roll back the most recent migration | -| `pnpm migration:history` | View migration history | -| `pnpm migration:dry-run` | Simulate a rollback without executing | - -### Preflight Validation - -The migration service performs these checks before applying changes: - -1. **Database Connectivity** — Verifies the database is reachable -2. **Schema State** — Inspects current schema for compatibility -3. **Pending Migrations** — Lists migrations waiting to be applied -4. **Environment Configuration** — Validates environment variables -5. **Destructive Migration Risk** — Detects potentially destructive operations - -### Rollback Plan - -Each preflight check generates a rollback plan describing the steps needed to revert the most recent migrations. Destructive migrations (DROP, TRUNCATE) are flagged as non-reversible. - -## Notification Providers (#388) - -Notifications are delivered through a centralized provider interface (`INotificationProvider`), supporting multiple channels: - -| Provider | ID | Description | -| -------- | -------- | ----------------------------------------------------------- | -| Email | `email` | Sends notifications via email with template personalization | -| Push | `push` | Delivers push notifications to user devices | -| In-App | `in-app` | Stores notifications in the user's in-app feed | - -Configure enabled providers via `NOTIFICATION_PROVIDERS` (comma-separated: `email,push,in-app`). - -## Email Template Fallbacks (#387) - -Email templates use `{{placeholder}}` syntax for personalization. When user data is incomplete, -missing fields are replaced with sensible defaults so content never renders blank: - -| Placeholder | Fallback | -| --------------------- | ------------------------------- | -| `{{name}}` | "RustAcademy Learner" | -| `{{courseName}}` | "your course" | -| `{{milestoneName}}` | "a new milestone" | -| `{{submissionTitle}}` | "your submission" | -| Any unrecognized key | `[key]` (safe bracket notation) | - -## Notification Batching (#386) - -Low-priority reminders (streak nudges, course suggestions) can be grouped into batches -to reduce noise and improve delivery efficiency: - -| Variable | Default | Description | -| ------------------------------ | ------- | --------------------------------- | -| `NOTIFICATION_BATCH_ENABLED` | `false` | Enable batching | -| `NOTIFICATION_BATCH_MAX_SIZE` | `10` | Max notifications per batch | -| `NOTIFICATION_BATCH_WINDOW_MS` | `30000` | Auto-flush window in milliseconds | - ---- - -# Backend Guide for shadcn/ui - -When integrating a frontend built with **shadcn/ui**, backend endpoints should provide consistent and predictable JSON responses to simplify component integration. - -## Success Response - -```json -{ - "success": true, - "data": {}, - "message": "Request completed successfully" -} -``` - -## Error Response - -```json -{ - "success": false, - "error": { - "code": "VALIDATION_ERROR", - "message": "Invalid request", - "fields": { - "email": "Email is required" - } - } -} -``` - -## Recommendations - -- Return consistent response structures. -- Use proper HTTP status codes. -- Include field-level validation errors. -- Support pagination for table components. -- Keep payloads predictable for frontend consumers. -- Avoid exposing internal implementation details. - -## Example Table Response - -```json -{ - "success": true, - "data": { - "items": [], - "pagination": { - "page": 1, - "limit": 10, - "total": 0 - } - } -} -``` - -## Example Select Response - -```json -{ - "success": true, - "data": [ - { - "label": "Admin", - "value": "admin" - }, - { - "label": "User", - "value": "user" - } - ] -} -``` diff --git a/BackendAcademy/src/admin/admin.controller.ts b/BackendAcademy/src/admin/admin.controller.ts deleted file mode 100644 index ce22ad662b..0000000000 --- a/BackendAcademy/src/admin/admin.controller.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Controller, Get } from '@nestjs/common'; -import { AdminService } from './admin.service'; -import { LocalizationService } from '../i18n/localization.service'; - -@Controller('admin') -export class AdminController { - constructor( - private readonly adminService: AdminService, - private readonly l10n: LocalizationService, - ) {} - - @Get('analytics/summary') - async getDashboardSummary() { - const summary = await this.adminService.getDashboardSummary(); - return { - labels: { - title: this.l10n.t('admin.dashboard.title'), - totalUsers: this.l10n.t('admin.dashboard.totalUsers'), - activeTutors: this.l10n.t('admin.dashboard.activeTutors'), - totalCourses: this.l10n.t('admin.dashboard.totalCourses'), - completionRate: this.l10n.t('admin.dashboard.completionRate'), - }, - data: summary, - }; - } -} diff --git a/BackendAcademy/src/admin/admin.module.ts b/BackendAcademy/src/admin/admin.module.ts deleted file mode 100644 index 499c2f551e..0000000000 --- a/BackendAcademy/src/admin/admin.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Module } from '@nestjs/common'; -import { AdminController } from './admin.controller'; -import { AdminService } from './admin.service'; -import { SubmissionsModule } from '../submissions/submissions.module'; - -@Module({ - imports: [SubmissionsModule], - controllers: [AdminController], - providers: [AdminService], - exports: [AdminService], -}) -export class AdminModule {} diff --git a/BackendAcademy/src/admin/admin.service.spec.ts b/BackendAcademy/src/admin/admin.service.spec.ts deleted file mode 100644 index 53217ca1af..0000000000 --- a/BackendAcademy/src/admin/admin.service.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { AdminService } from './admin.service'; - -describe('AdminService', () => { - let service: AdminService; - - beforeEach(() => { - service = new AdminService(); - }); - - it('returns a summary payload for admin analytics', async () => { - const result = await service.getDashboardSummary(); - - expect(result).toEqual( - expect.objectContaining({ - totalUsers: expect.any(Number), - activeTutors: expect.any(Number), - totalCourses: expect.any(Number), - completionRate: expect.any(Number), - }), - ); - }); -}); diff --git a/BackendAcademy/src/admin/admin.service.ts b/BackendAcademy/src/admin/admin.service.ts deleted file mode 100644 index b1ccf04eb6..0000000000 --- a/BackendAcademy/src/admin/admin.service.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { SubmissionsService, ReviewQueueMetrics, FlaggedSubmission } from '../submissions/submissions.service'; -import { ReportsService, ReportTriageEntry } from '../reports/reports.service'; -import { SocialService, SocialPost, ModerationStatus } from '../social/social.service'; -import { CorrelationLoggerService } from '../logging/logger.service'; - -/** - * #351: Audit log entry for privileged admin actions. - */ -export interface AuditLogEntry { - id: string; - action: string; - actorId: string; - targetType: string; - targetId: string; - previousValue?: unknown; - newValue?: unknown; - metadata?: Record; - timestamp: Date; - correlationId?: string; -} - -export interface AdminDashboardSummary { - totalUsers: number; - activeTutors: number; - totalCourses: number; - completionRate: number; -} - -export interface ReviewQueueDashboard { - summary: AdminDashboardSummary; - reviewQueue: ReviewQueueMetrics; - recentFlags: FlaggedSubmission[]; -} - -export interface ModerationQueueSummary { - pending: number; - flagged: number; - total: number; -} - -@Injectable() -export class AdminService { - /** #351: Audit log for all privileged actions. */ - private readonly auditLog: AuditLogEntry[] = []; - - constructor( - private readonly submissionsService: SubmissionsService, - private readonly reportsService: ReportsService, - private readonly socialService: SocialService, - ) {} - - // ────────────────────────────────────────────────────────────────── - // #351: Audit logging - // ────────────────────────────────────────────────────────────────── - - private recordAudit( - action: string, - actorId: string, - targetType: string, - targetId: string, - options: { - previousValue?: unknown; - newValue?: unknown; - metadata?: Record; - } = {}, - ): AuditLogEntry { - const correlationId = CorrelationLoggerService.getCorrelationId(); - const entry: AuditLogEntry = { - id: crypto.randomUUID(), - action, - actorId, - targetType, - targetId, - previousValue: options.previousValue, - newValue: options.newValue, - metadata: options.metadata, - timestamp: new Date(), - correlationId, - }; - this.auditLog.push(entry); - return entry; - } - - /** - * Returns the full audit log, optionally filtered by action type or actor. - */ - getAuditLog(filters?: { action?: string; actorId?: string; limit?: number }): AuditLogEntry[] { - let entries = [...this.auditLog]; - - if (filters?.action) { - entries = entries.filter((e) => e.action === filters.action); - } - if (filters?.actorId) { - entries = entries.filter((e) => e.actorId === filters.actorId); - } - - entries.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); - - if (filters?.limit) { - entries = entries.slice(0, filters.limit); - } - - return entries; - } - - // ────────────────────────────────────────────────────────────────── - // Dashboard - // ────────────────────────────────────────────────────────────────── - - async getDashboardSummary(): Promise { - return { - totalUsers: 128, - activeTutors: 24, - totalCourses: 41, - completionRate: 0.67, - }; - } - - async getReviewQueueDashboard(): Promise { - const summary = await this.getDashboardSummary(); - const reviewQueue = this.submissionsService.getQueueMetrics(); - const recentFlags = this.submissionsService.getFlaggedSubmissions().slice(-10); - return { summary, reviewQueue, recentFlags }; - } - - async assignModerator(flagId: string, moderatorId: string): Promise { - const result = await this.submissionsService.assignReviewer(flagId, moderatorId); - this.recordAudit('assign_moderator', moderatorId, 'flag', flagId, { - newValue: { assignedTo: moderatorId }, - }); - return result; - } - - async getFlaggedSubmissions(status?: string): Promise { - return this.submissionsService.getFlaggedSubmissions(status as any); - } - - async getReviewQueueMetrics(): Promise { - return this.submissionsService.getQueueMetrics(); - } - - async dismissFlag(flagId: string, dismissedBy: string): Promise { - const result = await this.submissionsService.dismissFlag(flagId, dismissedBy); - this.recordAudit('dismiss_flag', dismissedBy, 'flag', flagId); - return result; - } - - // ────────────────────────────────────────────────────────────────── - // Report triage - // ────────────────────────────────────────────────────────────────── - - getPendingReports(): ReportTriageEntry[] { - return this.reportsService.getAllReports('submitted'); - } - - getReportsInTriage(): ReportTriageEntry[] { - return this.reportsService.getAllReports('triage'); - } - - assignReport(reportId: string, adminId: string): ReportTriageEntry { - const report = this.reportsService.transitionReportStatus(reportId, adminId, 'triage', `Assigned to ${adminId}`); - report.assignedTo = adminId; - this.recordAudit('assign_report', adminId, 'report', reportId, { - newValue: { assignedTo: adminId }, - }); - return report; - } - - escalateReport(reportId: string, adminId: string, note: string): ReportTriageEntry { - this.recordAudit('escalate_report', adminId, 'report', reportId, { metadata: { note } }); - return this.reportsService.transitionReportStatus(reportId, adminId, 'escalated', note); - } - - resolveReport(reportId: string, adminId: string, note: string): ReportTriageEntry { - this.recordAudit('resolve_report', adminId, 'report', reportId, { metadata: { note } }); - return this.reportsService.transitionReportStatus(reportId, adminId, 'resolved', note); - } - - dismissReport(reportId: string, adminId: string, note: string): ReportTriageEntry { - this.recordAudit('dismiss_report', adminId, 'report', reportId, { metadata: { note } }); - return this.reportsService.transitionReportStatus(reportId, adminId, 'dismissed', note); - } - - // ────────────────────────────────────────────────────────────────── - // Content moderation - // ────────────────────────────────────────────────────────────────── - - getModerationQueueSummary(): ModerationQueueSummary { - const queue = this.socialService.getModerationQueue(); - const pending = queue.filter((p) => p.moderationStatus === 'pending').length; - const flagged = queue.filter((p) => p.moderationStatus === 'flagged').length; - return { pending, flagged, total: queue.length }; - } - - getPendingContent(): SocialPost[] { - return this.socialService.getPendingPosts(); - } - - getFlaggedContent(): SocialPost[] { - return this.socialService.getFlaggedPosts(); - } - - moderatePost(postId: string, moderatorId: string, status: ModerationStatus, reason?: string): SocialPost { - this.recordAudit('moderate_post', moderatorId, 'post', postId, { - newValue: { status, reason }, - }); - return this.socialService.moderatePost(postId, moderatorId, { status, reason }); - } - - bulkModerate(moderatorId: string, actions: Array<{ postId: string; status: ModerationStatus; reason?: string }>): number { - const count = this.socialService.bulkModerate(moderatorId, actions); - this.recordAudit('bulk_moderate', moderatorId, 'post', 'bulk', { - newValue: { count, actions }, - }); - return count; - } -} diff --git a/BackendAcademy/src/ai/ai.controller.ts b/BackendAcademy/src/ai/ai.controller.ts deleted file mode 100644 index eae3969d51..0000000000 --- a/BackendAcademy/src/ai/ai.controller.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { Controller, Get, Post, Body, Param } from '@nestjs/common'; -import { AiService } from './ai.service'; -import { CreateChatRequestDto } from './dto/create-chat-request.dto'; -import { GetHintDto } from './dto/get-hint.dto'; -import { PreScoreDto } from './dto/pre-score.dto'; -import { VoiceInteractionDto } from './dto/voice-interaction.dto'; -import { TtsRequestDto } from './dto/tts-request.dto'; -import { ChatMessage, HintUsageAnalytics } from './interfaces/ai.interface'; - -@Controller('ai') -export class AiController { - constructor(private readonly aiService: AiService) {} - - @Post('chat') - async sendChatMessage(@Body() createChatRequestDto: CreateChatRequestDto) { - return this.aiService.processChatRequest(createChatRequestDto); - } - - @Post('hint') - async getHint(@Body() getHintDto: GetHintDto) { - return this.aiService.getHint(getHintDto); - } - - @Get('hints/analytics') - async getHintUsageAnalytics(): Promise { - return this.aiService.getHintUsageAnalytics(); - } - - @Post('pre-score') - async preScore(@Body() dto: PreScoreDto) { - return this.aiService.preScore(dto); - } - - @Get('history/:userId') - async getChatHistory( - @Param('userId') userId: string, - ): Promise { - return this.aiService.getChatHistory(userId); - } - - @Post('voice') - async processVoice(@Body() dto: VoiceInteractionDto) { - return this.aiService.processVoice(dto); - } - - @Post('tts') - async generateTts(@Body() dto: TtsRequestDto) { - return this.aiService.generateTts(dto); - } -} diff --git a/BackendAcademy/src/ai/ai.module.spec.ts b/BackendAcademy/src/ai/ai.module.spec.ts deleted file mode 100644 index 09849a38da..0000000000 --- a/BackendAcademy/src/ai/ai.module.spec.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { ConfigService } from '@nestjs/config'; -import { validateAiConfig } from './ai.module'; - -function configService(values: Record): ConfigService { - return { get: (key: string): T | undefined => values[key] as T } as ConfigService; -} - -describe('validateAiConfig — BA-076 startup credential validation', () => { - it('accepts mock mode without any API keys', () => { - expect(() => - validateAiConfig(configService({ AI_PROVIDER: 'mock' })), - ).not.toThrow(); - }); - - it('accepts an unset AI_PROVIDER (defaults to mock)', () => { - expect(() => validateAiConfig(configService({}))).not.toThrow(); - }); - - it('accepts openai when OPENAI_API_KEY is set', () => { - expect(() => - validateAiConfig( - configService({ AI_PROVIDER: 'openai', OPENAI_API_KEY: 'sk-test' }), - ), - ).not.toThrow(); - }); - - it('accepts claude when ANTHROPIC_API_KEY is set', () => { - expect(() => - validateAiConfig( - configService({ AI_PROVIDER: 'claude', ANTHROPIC_API_KEY: 'sk-ant-test' }), - ), - ).not.toThrow(); - }); - - it('rejects openai without OPENAI_API_KEY with an actionable message', () => { - expect(() => validateAiConfig(configService({ AI_PROVIDER: 'openai' }))).toThrow( - /AI_PROVIDER is "openai" but OPENAI_API_KEY is not set/, - ); - }); - - it('rejects claude without ANTHROPIC_API_KEY with an actionable message', () => { - expect(() => validateAiConfig(configService({ AI_PROVIDER: 'claude' }))).toThrow( - /AI_PROVIDER is "claude" but ANTHROPIC_API_KEY is not set/, - ); - }); - - it('rejects an empty (whitespace) key value', () => { - expect(() => - validateAiConfig(configService({ AI_PROVIDER: 'openai', OPENAI_API_KEY: ' ' })), - ).toThrow(/OPENAI_API_KEY is not set/); - }); - - it('does not leak credential values in error messages', () => { - let message = ''; - try { - validateAiConfig(configService({ AI_PROVIDER: 'claude' })); - } catch (err) { - message = (err as Error).message; - } - expect(message).not.toContain('sk-ant'); - expect(message).toContain('ANTHROPIC_API_KEY'); - }); -}); diff --git a/BackendAcademy/src/ai/ai.module.ts b/BackendAcademy/src/ai/ai.module.ts deleted file mode 100644 index 1e64927f19..0000000000 --- a/BackendAcademy/src/ai/ai.module.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { AiController } from './ai.controller'; -import { AiService, AI_PROVIDER } from './ai.service'; -import { PromptTemplateService } from './prompt-template.service'; -import { ClaudeProvider } from './providers/claude.provider'; -import { OpenaiProvider } from './providers/openai.provider'; - -/** - * BA-076: Validate the AI configuration at startup, before any request - * arrives. - * - * Rules: - * - `AI_PROVIDER=openai` requires `OPENAI_API_KEY`. - * - `AI_PROVIDER=claude` requires `ANTHROPIC_API_KEY`. - * - `AI_PROVIDER=mock` (or unset) requires no credentials. - * - * Error messages are actionable and sanitized: they name the missing - * variable and the fix, but never echo credential values. - */ -export function validateAiConfig(configService: ConfigService): void { - const provider = configService.get('AI_PROVIDER') ?? 'mock'; - - if (provider === 'openai' || provider === 'claude') { - const credentialsKey = - provider === 'openai' ? 'OPENAI_API_KEY' : 'ANTHROPIC_API_KEY'; - const apiKey = configService.get(credentialsKey); - if (!apiKey || apiKey.trim().length === 0) { - throw new Error( - `AI_PROVIDER is "${provider}" but ${credentialsKey} is not set. ` + - `Set ${credentialsKey} to your API key, or switch AI_PROVIDER to "mock" for local development.`, - ); - } - } - - const numericParams: Array<{ key: string; min: number; max: number; integer?: boolean }> = [ - { key: 'AI_TEMPERATURE', min: 0, max: 2 }, - { key: 'AI_TOP_P', min: 0, max: 1 }, - { key: 'AI_MAX_TOKENS', min: 1, max: 200000, integer: true }, - { key: 'AI_FREQUENCY_PENALTY', min: -2, max: 2 }, - { key: 'AI_PRESENCE_PENALTY', min: -2, max: 2 }, - ]; - - for (const param of numericParams) { - const raw = configService.get(param.key); - if (raw === undefined || raw === null || raw === '') continue; - const num = Number(raw); - if (Number.isNaN(num)) { - throw new Error(`AI config ${param.key} must be a number, got "${raw}".`); - } - if (param.integer && !Number.isInteger(num)) { - throw new Error(`AI config ${param.key} must be an integer, got "${raw}".`); - } - if (num < param.min || num > param.max) { - throw new Error(`AI config ${param.key} must be between ${param.min} and ${param.max}, got "${raw}".`); - } - process.env[param.key] = String(num); - } - - const boolParams = ['AI_ENABLE_STREAMING', 'AI_ENABLE_LOGGING']; - for (const key of boolParams) { - const raw = configService.get(key); - if (raw === undefined || raw === null || raw === '') continue; - const lower = raw.toLowerCase(); - if (['true', '1', 'yes', 'y', 'on'].includes(lower)) { - process.env[key] = 'true'; - } else if (['false', '0', 'no', 'n', 'off'].includes(lower)) { - process.env[key] = 'false'; - } else { - throw new Error(`AI config ${key} must be a boolean, got "${raw}".`); - } - } -} - -const aiProviderFactory = { - provide: AI_PROVIDER, - useFactory: (configService: ConfigService) => { - validateAiConfig(configService); - const provider = configService.get('AI_PROVIDER'); - if (provider === 'openai') return new OpenaiProvider(configService); - if (provider === 'claude') return new ClaudeProvider(configService); - return null; - }, - inject: [ConfigService], -}; - -@Module({ - controllers: [AiController], - providers: [AiService, PromptTemplateService, aiProviderFactory], - exports: [AiService, PromptTemplateService], -}) -export class AiModule {} diff --git a/BackendAcademy/src/ai/ai.service.spec.ts b/BackendAcademy/src/ai/ai.service.spec.ts deleted file mode 100644 index a1457b9259..0000000000 --- a/BackendAcademy/src/ai/ai.service.spec.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { ConfigService } from '@nestjs/config'; -import { AiService } from './ai.service'; -import { AiProvider, AiProviderError, AiProviderErrorCode } from './interfaces/ai-provider.interface'; - -function configService(values: Record): ConfigService { - return { get: (key: string): T | undefined => values[key] as T } as ConfigService; -} - -describe('AiService', () => { - const fallbackMessages = [ - "That's a great question! Let me help you work through that. Based on what you've shared, I think the first thing you should understand is the core concept behind the problem.", - "Good thinking! You're on the right track. To move forward, I'd recommend reviewing the documentation on this topic and trying to implement a small piece first.", - ]; - - describe('processChatRequest', () => { - it('uses the normalized provider result content (BA-079)', async () => { - const provider: AiProvider = { - generateChatCompletion: jest.fn(async () => ({ - content: 'Normalized provider answer', - provider: 'openai', - model: 'gpt-4o', - })), - }; - - const service = new AiService( - provider, - configService({ AI_PROMPT_TEMPLATE_VERSION: '1.0.0' }), - ); - const result = await service.processChatRequest({ - message: 'What is ownership?', - userId: 'user-1', - }); - - expect(result.response).toBe('Normalized provider answer'); - expect(provider.generateChatCompletion).toHaveBeenCalledTimes(1); - }); - - it('falls back to a static response when the provider throws a domain error', async () => { - const provider: AiProvider = { - generateChatCompletion: jest.fn(async () => { - throw new AiProviderError( - AiProviderErrorCode.UPSTREAM_ERROR, - 'OpenAI provider returned an upstream error (HTTP 500)', - { status: 500, retryable: true }, - ); - }), - }; - - const service = new AiService(provider, configService({})); - const result = await service.processChatRequest({ - message: 'Explain traits', - userId: 'user-2', - }); - - expect(fallbackMessages).toContain(result.response); - }); - - it('falls back when no provider is configured', async () => { - const service = new AiService(undefined, configService({})); - const result = await service.processChatRequest({ - message: 'Explain lifetimes', - userId: 'user-3', - }); - - expect(fallbackMessages).toContain(result.response); - }); - - it('stores chat history and chat records per user', async () => { - const provider: AiProvider = { - generateChatCompletion: jest.fn(async () => ({ - content: 'answer', - provider: 'openai', - })), - }; - - const service = new AiService(provider, configService({})); - await service.processChatRequest({ message: 'hi', userId: 'user-4' }); - - expect(await service.getChatHistory('user-4')).toHaveLength(1); - expect(service.listChatRecords('user-4')).toHaveLength(1); - }); - }); - - describe('getHint', () => { - it('returns the built-in sample hint for the sample challenge', async () => { - const service = new AiService(undefined, configService({})); - const hint = await service.getHint({ userId: 'user-1', challengeId: 'sample-challenge-001', difficulty: 1 }); - expect(hint.hint).toBe('Start by understanding the problem requirements thoroughly.'); - }); - - it('returns a fallback message for unknown challenges', async () => { - const service = new AiService(undefined, configService({})); - const hint = await service.getHint({ userId: 'user-1', challengeId: 'nope', difficulty: 1 }); - expect(hint.hint).toContain('No hints available'); - }); - - it('records user-scoped, deduplicated hint usage (BA-081)', async () => { - const service = new AiService(undefined, configService({})); - - await service.getHint({ userId: 'user-1', challengeId: 'sample-challenge-001', difficulty: 1 }); - await service.getHint({ userId: 'user-1', challengeId: 'sample-challenge-001', difficulty: 1 }); - await service.getHint({ userId: 'user-2', challengeId: 'sample-challenge-001', difficulty: 1 }); - await service.getHint({ userId: 'user-2', challengeId: 'sample-challenge-001', difficulty: 2 }); - - const analytics = await service.getHintUsageAnalytics(); - - // Same user requesting the same hint twice is one deduplicated record. - expect(analytics.uniqueUsers).toBe(2); - expect(analytics.totalUses).toBe(4); - expect(analytics.records).toHaveLength(3); - - const firstHint = analytics.records.find( - (r) => r.userId === 'user-1' && r.difficulty === 1, - ); - expect(firstHint?.usedCount).toBe(2); - - // Difficulty distribution is captured for calibration. - expect(analytics.usesByDifficulty[1]).toBe(3); - expect(analytics.usesByDifficulty[2]).toBe(1); - }); - - it('survives a restart by persisting through RedisService (BA-081)', async () => { - const redis = new (require('../redis/redis.service').RedisService)(); - const service = new AiService(undefined, configService({}), undefined, redis); - - await service.getHint({ userId: 'user-1', challengeId: 'sample-challenge-001', difficulty: 1 }); - - // A fresh service instance (simulating a restart) reads from Redis. - const restarted = new AiService(undefined, configService({}), undefined, redis); - const analytics = await restarted.getHintUsageAnalytics(); - - expect(analytics.totalUses).toBe(1); - expect(analytics.uniqueUsers).toBe(1); - expect(analytics.records[0].userId).toBe('user-1'); - }); - }); - - describe('preScore', () => { - it('scores a complete solution higher than a stub', async () => { - const service = new AiService(undefined, configService({})); - const good = await service.preScore({ - userId: 'user-1', - taskId: 't1', - code: '// main entry point\nfn main() {\n let value = compute();\n println!("{}", value);\n}\n\nfn compute() -> i32 {\n 42\n}', - }); - const stub = await service.preScore({ - userId: 'user-1', - taskId: 't2', - code: '// TODO', - }); - - expect(good.predictedScore).toBeGreaterThan(stub.predictedScore); - }); - - it('rejects oversized submissions', async () => { - const service = new AiService(undefined, configService({})); - await expect( - service.preScore({ userId: 'user-1', taskId: 't3', code: '// '.repeat(20_001) }), - ).rejects.toThrow(/exceeds maximum length/); - }); - }); -}); diff --git a/BackendAcademy/src/ai/ai.service.ts b/BackendAcademy/src/ai/ai.service.ts deleted file mode 100644 index 5ae88a1b38..0000000000 --- a/BackendAcademy/src/ai/ai.service.ts +++ /dev/null @@ -1,696 +0,0 @@ -import { Inject, Injectable, Logger, Optional } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { CreateChatRequestDto } from './dto/create-chat-request.dto'; -import { GetHintDto } from './dto/get-hint.dto'; -import { PreScoreDto } from './dto/pre-score.dto'; -import { VoiceInteractionDto } from './dto/voice-interaction.dto'; -import { TtsRequestDto } from './dto/tts-request.dto'; -import { - AiChatResponse, - AiChatRecord, - AiHintResponse, - AiRecommendationResponse, - ChatMessage, - Hint, - HintUsageAnalytics, - HintUsageRecord, - VoiceInteractionResponse, - TtsResponse, -} from './interfaces/ai.interface'; -import { PreScoreResult } from './interfaces/pre-score.interface'; -import { AiProvider, ProviderChatResult } from './interfaces/ai-provider.interface'; -import { PromptTemplateService } from './prompt-template.service'; -import { v4 as uuidv4 } from 'uuid'; -import { AnalyticsService } from '../analytics/analytics.service'; -import { RedisService } from '../redis/redis.service'; -import { MonitoringService } from '../monitoring/monitoring.service'; -import { SecurityService } from '../security/security.service'; - -export const AI_PROVIDER = 'AI_PROVIDER'; - -const MAX_CHAT_HISTORY_PER_USER = 200; // bound in-memory growth per user -const MAX_TRACKED_USERS = 5_000; // bound total map size across users -const MAX_PRE_SCORE_CODE_LENGTH = 20_000; // guard against oversized submissions - -/// BA-081: Redis key prefix for durable hint usage records. -const HINT_USAGE_KEY_PREFIX = 'hint:usage:'; -/// BA-081: Redis key prefix for the per-hint user set (for unique-user counts). -const HINT_USERS_KEY_PREFIX = 'hint:users:'; -/// BA-081: Hint usage must survive restarts for calibration analytics; keep -/// records for 90 days instead of relying on the cache default TTL. -const HINT_USAGE_TTL_MS = 90 * 24 * 60 * 60 * 1000; - -@Injectable() -export class AiService { - private readonly logger = new Logger(AiService.name); - - private chatHistory: Map = new Map(); - private chatRecords: Map = new Map(); - private hints: Map = new Map(); - /** BA-081: Durable hint usage records keyed by `userId:hintId`. */ - private hintUsage: Map = new Map(); - private readonly defaultTimeoutMs: number; - private readonly maxChatHistoryLength: number; - - constructor( - @Optional() @Inject(AI_PROVIDER) private aiProvider?: AiProvider, - private configService?: ConfigService, - private readonly analyticsService?: AnalyticsService, - private readonly redisService?: RedisService, - private readonly monitoringService?: MonitoringService, - @Optional() private readonly promptTemplateService?: PromptTemplateService, - @Optional() private readonly securityService?: SecurityService, - ) { - this.defaultTimeoutMs = this.configService?.get('DEFAULT_REQUEST_TIMEOUT_MS') ?? 30_000; - this.maxChatHistoryLength = this.configService?.get('AI_MAX_CHAT_HISTORY_LENGTH') ?? 50; - this.initializeSampleHints(); - - // Surface missing optional dependencies loudly instead of failing silently. - if (!this.redisService) this.logger.warn('RedisService not injected — chat/recommendation state is process-local and non-durable.'); - if (!this.analyticsService) this.logger.warn('AnalyticsService not injected — pre-score events will not be tracked.'); - if (!this.monitoringService) this.logger.warn('MonitoringService not injected — domain events will not be recorded.'); - if (!this.aiProvider) this.logger.warn('AiProvider not injected — chat requests will use the static fallback response.'); - } - - async getRecommendation(userId: string): Promise { - const snapshot = this.redisService - ? await this.redisService.getUserSnapshot(userId) - : null; - - if (!snapshot) { - return { - userId, - recommendations: [], - explainability: { - factors: ['insufficient_data'], - confidence: 0.1, - userSignalAge: 0, - signalsUsed: [], - modelVersion: 'rustacademy-recommender-v2', - }, - generatedAt: new Date(), - }; - } - - const explainability = this.redisService - ? await this.redisService.getRecommendationExplainability(userId) - : null; - - const recommendedCourses = snapshot.recentCourses.length > 0 - ? snapshot.recentCourses.slice(0, 3) - : ['rust-fundamentals', 'smart-contracts-101', 'stellar-basics']; - - const recommendations = recommendedCourses.map((courseId, index) => ({ - courseId, - score: Math.max(0, 1 - index * 0.2 - (snapshot.interactionCount > 0 ? 0 : 0.3)), - reason: explainability?.factors[index] || 'course_popularity', - })); - - if (this.monitoringService) { - this.monitoringService.recordDomainEvent('recommendation_generated', 'ai'); - } - - return { - userId, - recommendations, - explainability: explainability || { - factors: [], - confidence: 0.1, - userSignalAge: 0, - signalsUsed: [], - modelVersion: 'rustacademy-recommender-v2', - }, - generatedAt: new Date(), - }; - } - - async processChatRequest( - createChatRequestDto: CreateChatRequestDto, - ): Promise { - const { message, userId, context } = createChatRequestDto; - - // Issue #371: sanitise user-supplied prompts before they reach the AI - // provider. When SecurityService is wired in, prompts containing known - // prompt-injection patterns are either wrapped in a hard system-pinned - // boundary or rejected outright. Without SecurityService we degrade - // gracefully (the previous behaviour) so unit tests keep working. - const sanitisation = this.securityService - ? this.securityService.sanitisePrompt(message) - : null; - - const effectiveMessage = sanitisation?.sanitised ?? message; - - const response = - sanitisation?.status === 'rejected' - ? sanitisation.sanitised - : await this.generateChatResponse(effectiveMessage); - - const chatMessage: ChatMessage = { - id: uuidv4(), - userId, - message, - response, - timestamp: new Date(), - context, - isComplete: true, - }; - - this.appendChatHistory(userId, chatMessage); - - // Fix: chatRecords was previously never populated, so getChatRecord()/ - // listChatRecords() always returned nothing. Record one entry per - // processed message here, keyed by the message id as its sessionId. - this.chatRecords.set(chatMessage.id, { - id: chatMessage.id, - sessionId: chatMessage.id, - userId, - messages: [chatMessage], - startedAt: chatMessage.timestamp, - lastActivityAt: chatMessage.timestamp, - }); - - // #372: Auto-summarise when history exceeds threshold - await this.autoSummarize(userId); - - // Track prompt template usage in metrics (#374) - if (this.monitoringService) { - this.monitoringService.recordDomainEvent('ai_prompt_template_used', 'ai'); - } - - if (this.redisService) { - await this.redisService.refreshUserSnapshot(userId, { - lastInteractionAt: new Date(), - interactionCount: 1, - eventTypes: ['chat_message'], - }); - } - - return { - response: chatMessage.response, - timestamp: chatMessage.timestamp, - messageId: chatMessage.id, - // Surface the sanitisation outcome so callers can audit unsafe inputs. - ...(sanitisation && sanitisation.status !== 'safe' - ? { - safety: { - status: sanitisation.status, - reasons: sanitisation.reasons, - originalLength: sanitisation.originalLength, - }, - } - : {}), - }; - } - - /** - * Calls the AI provider with the global request timeout (Issue #408) and - * falls back to a static response if the provider is unavailable, times - * out, or errors — so a flaky upstream never surfaces as a 500 to callers. - * - * The user message is sent together with the versioned chat system prompt - * from configuration (#374). BA-079: the provider returns the normalized - * {@link ProviderChatResult} model, so only `.content` is consumed here. - */ - private async generateChatResponse(message: string): Promise { - if (!this.aiProvider) { - return this.fallbackResponse(message); - } - - const systemPrompt = this.promptTemplateService - ? this.promptTemplateService.getSystemPrompt('chat_tutor', { - version: this.configService?.get('AI_PROMPT_TEMPLATE_VERSION'), - }) - : 'You are a helpful Rust programming tutor.'; - - try { - const result: ProviderChatResult = await this.withTimeout( - this.aiProvider.generateChatCompletion({ - messages: [ - { role: 'system', content: systemPrompt }, - { role: 'user', content: message }, - ], - }), - this.defaultTimeoutMs, - ); - return result.content; - } catch (err) { - this.logger.error('AI provider call failed, falling back to static response', err as Error); - if (this.monitoringService) { - this.monitoringService.recordDomainEvent('ai_provider_failure', 'ai'); - } - return this.fallbackResponse(message); - } - } - - private withTimeout(promise: Promise, timeoutMs: number): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout( - () => reject(new Error(`AI provider call timed out after ${timeoutMs}ms`)), - timeoutMs, - ); - promise - .then((value) => { - clearTimeout(timer); - resolve(value); - }) - .catch((err) => { - clearTimeout(timer); - reject(err); - }); - }); - } - - async getHint(getHintDto: GetHintDto): Promise { - const { challengeId, difficulty = 1, userId } = getHintDto; - - const challengeHints = this.hints.get(challengeId) || []; - - const hint = - challengeHints.find((h) => h.difficulty === difficulty) || - challengeHints[0]; - - if (!hint) { - return { - hint: 'No hints available for this challenge yet. Keep trying!', - hintId: uuidv4(), - difficulty: 1, - }; - } - - hint.usedCount++; - - // BA-081: Persist user-scoped hint usage so counts are durable and can - // support difficulty calibration across instances. Falls back to a - // process-local map when RedisService isn't injected. - await this.recordHintUsage(userId, challengeId, hint); - - return { - hint: hint.hint, - hintId: hint.id, - difficulty: hint.difficulty, - }; - } - - // ────────────────────────────────────────────────────────────────── - // BA-081: Hint usage analytics - // ────────────────────────────────────────────────────────────────── - - /** - * Persist a hint request, deduplicated per `userId:hintId`. - * - * The in-memory map is the source of truth when no RedisService is - * injected (unit tests). When it is, every record is mirrored to Redis - * under `hint:usage:{userId}:{hintId}` and every hint keeps a set of the - * users who used it (`hint:users:{hintId}`) so unique-user counts survive - * restarts and are correct across instances. - */ - private async recordHintUsage( - userId: string, - challengeId: string, - hint: Hint, - ): Promise { - const recordKey = `${userId}:${hint.id}`; - const existing = this.hintUsage.get(recordKey); - const now = new Date(); - - const record: HintUsageRecord = existing - ? { ...existing, usedCount: existing.usedCount + 1, lastUsedAt: now } - : { - hintId: hint.id, - challengeId, - difficulty: hint.difficulty, - userId, - usedCount: 1, - firstUsedAt: now, - lastUsedAt: now, - }; - this.hintUsage.set(recordKey, record); - - if (this.redisService) { - await Promise.all([ - this.redisService.set( - `${HINT_USAGE_KEY_PREFIX}${recordKey}`, - record, - HINT_USAGE_TTL_MS, - ), - this.redisService.sadd(`${HINT_USERS_KEY_PREFIX}${hint.id}`, userId), - ]); - } - } - - /** - * BA-081: Aggregate hint usage for analytics. Combines the process-local - * map with any records persisted in Redis so results are correct even - * after a restart or across replicas. - */ - async getHintUsageAnalytics(): Promise { - const records = await this.collectHintUsageRecords(); - - const usesByHint: Record = {}; - const usesByDifficulty: Record = {}; - const uniqueUserIds = new Set(); - let totalUses = 0; - - for (const record of records) { - usesByHint[record.hintId] = (usesByHint[record.hintId] ?? 0) + record.usedCount; - usesByDifficulty[record.difficulty] = - (usesByDifficulty[record.difficulty] ?? 0) + record.usedCount; - uniqueUserIds.add(record.userId); - totalUses += record.usedCount; - } - - return { - totalUses, - uniqueUsers: uniqueUserIds.size, - records, - usesByHint, - usesByDifficulty, - }; - } - - /** - * BA-081: Fetch every persisted hint usage record, merging the local map - * with Redis state (Redis wins on key collision since it may contain data - * from another instance). - */ - private async collectHintUsageRecords(): Promise { - const merged = new Map(this.hintUsage); - - if (this.redisService) { - const persistedKeys = await this.redisService.getKeys( - `${HINT_USAGE_KEY_PREFIX}*`, - ); - for (const key of persistedKeys) { - const stored = (await this.redisService.get(key)) as - | HintUsageRecord - | null - | undefined; - if (stored && stored.hintId) { - merged.set(key.replace(HINT_USAGE_KEY_PREFIX, ''), stored); - } - } - } - - return Array.from(merged.values()).sort( - (a, b) => b.lastUsedAt.getTime() - a.lastUsedAt.getTime(), - ); - } - - async preScore(dto: PreScoreDto): Promise { - const { taskId, code } = dto; - - if (code.length > MAX_PRE_SCORE_CODE_LENGTH) { - throw new Error( - `Submission exceeds maximum length of ${MAX_PRE_SCORE_CODE_LENGTH} characters`, - ); - } - - const lines = code.split('\n').filter((l) => l.trim().length > 0).length; - const hasComments = code.includes('//') || code.includes('/*'); - const hasFunctions = code.includes('fn '); - const hasMain = code.includes('fn main'); - - let score = 50; - const strengths: string[] = []; - const weaknesses: string[] = []; - const suggestions: string[] = []; - - if (hasMain) { - score += 15; - strengths.push('Includes a main function entry point'); - } else { - weaknesses.push('No main function found'); - suggestions.push('Add a fn main() entry point to your program'); - } - - if (hasFunctions && lines > 5) { - score += 15; - strengths.push('Code is organized into functions'); - } else if (lines <= 5) { - weaknesses.push('Very short submission - may be incomplete'); - suggestions.push('Expand your solution with proper implementation'); - } - - if (hasComments) { - score += 10; - strengths.push('Code includes helpful comments'); - } else { - suggestions.push('Consider adding comments to explain your logic'); - } - - if (lines > 20) { - score += 10; - strengths.push('Comprehensive implementation'); - } - - score = Math.min(100, Math.max(0, score)); - - if (this.analyticsService) { - await this.analyticsService.trackEvent({ - id: uuidv4(), - eventType: 'submission_prescore', - properties: { taskId, score, lines }, - }); - } - - return { - taskId, - predictedScore: score, - confidence: 0.7, - feedback: - score >= 70 - ? 'Your submission looks promising. Keep refining!' - : 'Your submission needs improvement. Review the suggestions below.', - strengths, - weaknesses, - suggestions, - evaluatedAt: new Date(), - }; - } - - // ────────────────────────────────────────────────────────────────── - // Chat history management (#372, #373) - // ────────────────────────────────────────────────────────────────── - - async getChatHistory(userId: string): Promise { - return this.chatHistory.get(userId) || []; - } - - /** - * #373: Marks a message as incomplete when a chat streaming disconnect - * leaves a partial response in state. Incomplete messages can be cleaned - * up or shown to the user with a warning. - */ - markMessageIncomplete(userId: string, messageId: string): boolean { - const history = this.chatHistory.get(userId); - if (!history) return false; - - const msg = history.find((m) => m.id === messageId); - if (!msg) return false; - - msg.isComplete = false; - - if (this.monitoringService) { - this.monitoringService.recordDomainEvent('chat_streaming_disconnect', 'ai'); - } - - this.logger.warn( - `Message ${messageId} for user ${userId} marked incomplete due to streaming disconnect`, - ); - return true; - } - - /** - * #373: Removes all incomplete messages from a user's chat history, - * preventing partial responses from persisting in conversation state. - */ - cleanupIncompleteMessages(userId: string): number { - const history = this.chatHistory.get(userId); - if (!history) return 0; - - const incompleteCount = history.filter((m) => !m.isComplete).length; - const cleaned = history.filter((m) => m.isComplete); - this.chatHistory.set(userId, cleaned); - - if (incompleteCount > 0) { - this.logger.log( - `Cleaned up ${incompleteCount} incomplete messages for user ${userId}`, - ); - if (this.monitoringService) { - this.monitoringService.recordDomainEvent('chat_incomplete_messages_cleaned', 'ai'); - } - } - return incompleteCount; - } - - /** - * #372: Automatically generates a conversation summary when chat history - * exceeds the configured maxChatHistoryLength. Older messages are compacted - * into a summary string to keep token usage under control during long - * tutoring sessions. - */ - private async autoSummarize(userId: string): Promise { - const history = this.chatHistory.get(userId); - if (!history || history.length <= this.maxChatHistoryLength) return; - - const excess = history.length - this.maxChatHistoryLength; - const olderMessages = history.slice(0, excess); - const recentMessages = history.slice(excess); - - // Build a compact summary from older messages - const topicSummary = this.buildConversationSummary(olderMessages); - - // Store the summary on the most relevant chat record or create one - const existingRecord = Array.from(this.chatRecords.values()).find( - (r) => r.userId === userId, - ); - - const summaryText = `[Conversation summary — ${new Date().toISOString()}]: ${topicSummary}`; - - if (existingRecord) { - existingRecord.summary = existingRecord.summary - ? `${existingRecord.summary}\n${summaryText}` - : summaryText; - existingRecord.lastSummaryAt = new Date(); - } else { - const newRecord: AiChatRecord = { - id: uuidv4(), - userId, - sessionId: `session-${Date.now()}`, - messages: recentMessages, - startedAt: olderMessages[0]?.timestamp ?? new Date(), - lastActivityAt: new Date(), - summary: summaryText, - lastSummaryAt: new Date(), - }; - this.chatRecords.set(newRecord.id, newRecord); - } - - // Keep only the most recent messages in active history - this.chatHistory.set(userId, recentMessages); - - if (this.monitoringService) { - this.monitoringService.recordDomainEvent('chat_summary_generated', 'ai'); - } - - this.logger.log( - `Auto-summarised ${excess} messages for user ${userId} (${recentMessages.length} retained)`, - ); - } - - /** - * #372: Builds a compact conversation summary from a list of chat messages. - * Extracts key topics and user questions without storing the full text. - */ - private buildConversationSummary(messages: ChatMessage[]): string { - if (messages.length === 0) return 'No prior conversation.'; - - const userMessages = messages - .filter((m) => m.message && m.message.trim().length > 0) - .map((m) => m.message.slice(0, 120)); - - if (userMessages.length === 0) return `${messages.length} interactions.`; - - const topics = userMessages.slice(0, 10).join('; '); - const topicPreview = - topics.length > 500 ? topics.slice(0, 500) + '...' : topics; - - return `${messages.length} messages covering: ${topicPreview}`; - } - - /** - * #372: Returns the current conversation summary for a user, if one exists. - */ - getConversationSummary(userId: string): string | null { - const record = Array.from(this.chatRecords.values()).find( - (r) => r.userId === userId, - ); - return record?.summary ?? null; - } - - getChatRecord(sessionId: string): AiChatRecord | null { - return this.chatRecords.get(sessionId) ?? null; - } - - listChatRecords(userId: string): AiChatRecord[] { - return Array.from(this.chatRecords.values()).filter((r) => r.userId === userId); - } - - async processVoice(dto: VoiceInteractionDto) { - const transcription = `[Transcribed: ${dto.audioData.slice(0, 50)}...]`; - const response: VoiceInteractionResponse = { - transcription, - confidence: 0.85, - processedAt: new Date(), - }; - return response; - } - - async generateTts(dto: TtsRequestDto) { - const response: TtsResponse = { - audioData: Buffer.from(dto.text).toString('base64'), - format: 'audio/wav', - durationMs: dto.text.length * 60, - }; - return response; - } - - private fallbackResponse(userMessage: string): string { - const responses = [ - "That's a great question! Let me help you work through that. Based on what you've shared, I think the first thing you should understand is the core concept behind the problem.", - "Good thinking! You're on the right track. To move forward, I'd recommend reviewing the documentation on this topic and trying to implement a small piece first.", - ]; - return responses[Math.floor(Math.random() * responses.length)]; - } - - private initializeSampleHints() { - const sampleHints: Hint[] = [ - { - id: uuidv4(), - challengeId: 'sample-challenge-001', - hint: 'Start by understanding the problem requirements thoroughly.', - difficulty: 1, - usedCount: 0, - }, - { - id: uuidv4(), - challengeId: 'sample-challenge-001', - hint: 'Consider edge cases - empty, null, or out-of-range inputs.', - difficulty: 2, - usedCount: 0, - }, - { - id: uuidv4(), - challengeId: 'sample-challenge-001', - hint: 'Implement brute-force first, then optimize.', - difficulty: 3, - usedCount: 0, - }, - ]; - - this.hints.set('sample-challenge-001', sampleHints); - } - - /** - * Bounded append: caps per-user history length and total tracked users - * so this in-memory map can't grow without limit in a long-lived process. - * This is a stopgap — durable, cross-instance history should move to - * Redis/Postgres via `redisService`, since chat state currently doesn't - * survive a restart or work across multiple API replicas. - */ - private appendChatHistory(userId: string, chatMessage: ChatMessage) { - if (!this.chatHistory.has(userId)) { - if (this.chatHistory.size >= MAX_TRACKED_USERS) { - const oldestKey = this.chatHistory.keys().next().value; - if (oldestKey !== undefined) this.chatHistory.delete(oldestKey); - } - this.chatHistory.set(userId, []); - } - - const history = this.chatHistory.get(userId)!; - history.push(chatMessage); - if (history.length > MAX_CHAT_HISTORY_PER_USER) { - history.splice(0, history.length - MAX_CHAT_HISTORY_PER_USER); - } - } -} diff --git a/BackendAcademy/src/ai/dto/create-chat-request.dto.ts b/BackendAcademy/src/ai/dto/create-chat-request.dto.ts deleted file mode 100644 index ff5367a2ad..0000000000 --- a/BackendAcademy/src/ai/dto/create-chat-request.dto.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { IsString, IsOptional, IsObject } from 'class-validator'; - -export class CreateChatRequestDto { - @IsString() - message: string; - - @IsOptional() - @IsObject() - context?: Record; - - @IsString() - userId: string; -} diff --git a/BackendAcademy/src/ai/dto/get-hint.dto.ts b/BackendAcademy/src/ai/dto/get-hint.dto.ts deleted file mode 100644 index 8a25392145..0000000000 --- a/BackendAcademy/src/ai/dto/get-hint.dto.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { IsString, IsOptional, IsNumber } from 'class-validator'; - -export class GetHintDto { - @IsString() - challengeId: string; - - @IsString() - userId: string; - - @IsOptional() - @IsNumber() - difficulty?: number; -} diff --git a/BackendAcademy/src/ai/dto/placeholder.txt b/BackendAcademy/src/ai/dto/placeholder.txt deleted file mode 100644 index dec3c8e7db..0000000000 --- a/BackendAcademy/src/ai/dto/placeholder.txt +++ /dev/null @@ -1 +0,0 @@ -Placeholder for dto directory \ No newline at end of file diff --git a/BackendAcademy/src/ai/dto/pre-score.dto.ts b/BackendAcademy/src/ai/dto/pre-score.dto.ts deleted file mode 100644 index bdaea7e8cd..0000000000 --- a/BackendAcademy/src/ai/dto/pre-score.dto.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { IsString, IsOptional, IsObject } from 'class-validator'; - -export class PreScoreDto { - @IsString() - userId: string; - - @IsString() - taskId: string; - - @IsString() - code: string; - - @IsOptional() - @IsObject() - metadata?: Record; -} diff --git a/BackendAcademy/src/ai/dto/tts-request.dto.ts b/BackendAcademy/src/ai/dto/tts-request.dto.ts deleted file mode 100644 index fa12bf1d9f..0000000000 --- a/BackendAcademy/src/ai/dto/tts-request.dto.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { IsString, IsNotEmpty, IsOptional } from 'class-validator'; - -export class TtsRequestDto { - @IsString() - @IsNotEmpty() - text: string; - - @IsString() - @IsOptional() - voice?: string; - - @IsString() - @IsOptional() - language?: string; -} diff --git a/BackendAcademy/src/ai/dto/voice-interaction.dto.ts b/BackendAcademy/src/ai/dto/voice-interaction.dto.ts deleted file mode 100644 index 343ce5e773..0000000000 --- a/BackendAcademy/src/ai/dto/voice-interaction.dto.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { IsString, IsNotEmpty } from 'class-validator'; - -export class VoiceInteractionDto { - @IsString() - @IsNotEmpty() - userId: string; - - @IsString() - @IsNotEmpty() - audioData: string; - - @IsString() - @IsNotEmpty() - language: string; -} diff --git a/BackendAcademy/src/ai/interfaces/ai-provider.interface.ts b/BackendAcademy/src/ai/interfaces/ai-provider.interface.ts deleted file mode 100644 index b8edfbc2fe..0000000000 --- a/BackendAcademy/src/ai/interfaces/ai-provider.interface.ts +++ /dev/null @@ -1,131 +0,0 @@ -export interface AiProviderConfig { - apiKey: string; - model?: string; - maxTokens?: number; - temperature?: number; -} - -export interface ChatCompletionMessage { - role: 'system' | 'user' | 'assistant'; - content: string; -} - -export interface ChatCompletionOptions { - messages: ChatCompletionMessage[]; - temperature?: number; - maxTokens?: number; -} - -/** - * Normalized result contract returned by every AI provider (BA-079). - * - * Providers convert their vendor-specific response shapes into this single - * internal model, so callers never depend on OpenAI vs Anthropic details. - */ -export interface ProviderChatResult { - /** The generated assistant reply text. */ - content: string; - /** Identifier of the provider that produced the result ('openai' | 'claude'). */ - provider: string; - /** Model identifier that served the request, when reported by the vendor. */ - model?: string; - /** Token usage reported by the vendor, when available. */ - usage?: { - promptTokens?: number; - completionTokens?: number; - totalTokens?: number; - }; -} - -/** - * Stable, machine-readable error codes for provider failures (BA-079). - * - * These codes are part of the internal contract: callers can branch on them - * and the values never embed raw vendor payloads. - */ -export enum AiProviderErrorCode { - /** The provider was selected but its API key is missing/empty. */ - MISSING_CREDENTIALS = 'MISSING_CREDENTIALS', - /** The provider returned a response that does not match its schema. */ - MALFORMED_RESPONSE = 'MALFORMED_RESPONSE', - /** The provider returned a well-formed response with no usable content. */ - EMPTY_RESPONSE = 'EMPTY_RESPONSE', - /** The provider is rate limiting the account (HTTP 429). */ - RATE_LIMITED = 'RATE_LIMITED', - /** The provider returned a 5xx or the upstream was unreachable. */ - UPSTREAM_ERROR = 'UPSTREAM_ERROR', - /** The request exceeded the configured timeout. */ - TIMEOUT = 'TIMEOUT', -} - -/** - * Domain error raised by AI providers and the AI service (BA-079, BA-078). - * - * The message is always sanitized: it never contains raw response bodies, - * API keys, or other credentials, so it is safe to surface in logs and - * error responses. - */ -export class AiProviderError extends Error { - readonly code: AiProviderErrorCode; - /** HTTP status observed from the vendor, when applicable. */ - readonly status?: number; - /** - * Whether a retry could succeed. Transient failures (429, 5xx, network) - * return `true`; schema/credential failures return `false`. - */ - readonly retryable: boolean; - /** Number of retry attempts already consumed for this error, if any. */ - readonly attempts?: number; - - constructor( - code: AiProviderErrorCode, - message: string, - options: { status?: number; retryable?: boolean; attempts?: number } = {}, - ) { - super(message); - this.name = 'AiProviderError'; - this.code = code; - this.status = options.status; - this.retryable = - options.retryable ?? - (code === AiProviderErrorCode.RATE_LIMITED || - code === AiProviderErrorCode.UPSTREAM_ERROR); - this.attempts = options.attempts; - } -} - -/** - * Retry policy for transient provider failures (BA-078). - * - * Only retryable failures (429, 5xx, network errors) are retried; attempts - * and inter-attempt delay are strictly bounded so a flaky vendor can never - * cause unbounded request amplification. - */ -export interface AiRetryPolicy { - /** Maximum number of retry attempts after the initial call (default 3). */ - maxAttempts: number; - /** Base delay in ms before the first retry (default 250). */ - baseDelayMs: number; - /** Upper bound in ms for the exponential backoff (default 5000). */ - maxDelayMs: number; -} - -export const DEFAULT_AI_RETRY_POLICY: AiRetryPolicy = { - maxAttempts: 3, - baseDelayMs: 250, - maxDelayMs: 5_000, -}; - -export interface AiProvider { - /** - * Generate a chat completion. - * - * Implementations MUST return the normalized {@link ProviderChatResult} - * and MUST throw {@link AiProviderError} for every failure mode, including - * malformed/empty vendor responses. - */ - generateChatCompletion( - options: ChatCompletionOptions, - retryPolicy?: AiRetryPolicy, - ): Promise; -} diff --git a/BackendAcademy/src/ai/interfaces/ai.interface.ts b/BackendAcademy/src/ai/interfaces/ai.interface.ts deleted file mode 100644 index ce11740bb0..0000000000 --- a/BackendAcademy/src/ai/interfaces/ai.interface.ts +++ /dev/null @@ -1,121 +0,0 @@ -export interface ChatMessage { - id: string; - userId: string; - message: string; - response: string; - timestamp: Date; - context?: Record; - /** Indicates whether the chat response was fully delivered (#373) */ - isComplete: boolean; -} - -export interface Hint { - id: string; - challengeId: string; - hint: string; - difficulty: number; - usedCount: number; -} - -export interface AiChatResponse { - response: string; - timestamp: Date; - messageId: string; - /** - * Issue #371 — prompt sanitisation outcome. Present only when the - * SecurityService flagged the user input. Omitted for clean inputs to - * keep the common path zero-cost. - */ - safety?: { - status: 'wrapped' | 'rejected'; - reasons: string[]; - originalLength: number; - }; -} - -export interface AiHintResponse { - hint: string; - hintId: string; - difficulty: number; -} - -/** - * BA-081: Durable, user-scoped record of a hint request. - * - * Keyed by `userId:hintId`, so a user requesting the same hint twice is - * tracked as a single record with an incremented `usedCount` rather than two - * separate rows. This is what lets analytics answer "how often is this hint - * used, by how many distinct users, and at what difficulty" without counting - * the same learner repeatedly. - */ -export interface HintUsageRecord { - hintId: string; - challengeId: string; - difficulty: number; - userId: string; - /** Number of times this user requested this hint. */ - usedCount: number; - firstUsedAt: Date; - lastUsedAt: Date; -} - -/** - * BA-081: Aggregated hint usage, queryable for calibration analytics. - */ -export interface HintUsageAnalytics { - totalUses: number; - uniqueUsers: number; - /** One entry per `userId:hintId`, deduplicated per user. */ - records: HintUsageRecord[]; - /** `hintId -> total uses across all users`. */ - usesByHint: Record; - /** `difficulty -> total uses across all users`. */ - usesByDifficulty: Record; -} - -export interface AiChatRecord { - id: string; - userId: string; - sessionId: string; - messages: ChatMessage[]; - startedAt: Date; - lastActivityAt: Date; - metadata?: Record; - /** Compact summary of conversation for long tutoring sessions (#372) */ - summary?: string; - /** Timestamp of the last summary generation (#372) */ - lastSummaryAt?: Date; -} - -export interface VoiceInteractionResponse { - transcription: string; - confidence: number; - processedAt: Date; -} - -export interface TtsResponse { - audioData: string; - format: string; - durationMs: number; -} - -export interface RecommendationItem { - courseId: string; - score: number; - reason: string; -} - -export interface ExplainabilityMetadata { - factors: string[]; - confidence: number; - userSignalAge: number; - signalsUsed: string[]; - modelVersion: string; -} - -export interface AiRecommendationResponse { - userId: string; - recommendations: RecommendationItem[]; - explainability: ExplainabilityMetadata; - generatedAt: Date; -} diff --git a/BackendAcademy/src/ai/interfaces/placeholder.txt b/BackendAcademy/src/ai/interfaces/placeholder.txt deleted file mode 100644 index b4201277f0..0000000000 --- a/BackendAcademy/src/ai/interfaces/placeholder.txt +++ /dev/null @@ -1 +0,0 @@ -Placeholder for interfaces directory \ No newline at end of file diff --git a/BackendAcademy/src/ai/interfaces/pre-score.interface.ts b/BackendAcademy/src/ai/interfaces/pre-score.interface.ts deleted file mode 100644 index 281ce0c6b4..0000000000 --- a/BackendAcademy/src/ai/interfaces/pre-score.interface.ts +++ /dev/null @@ -1,10 +0,0 @@ -export interface PreScoreResult { - taskId: string; - predictedScore: number; - confidence: number; - feedback: string; - strengths: string[]; - weaknesses: string[]; - suggestions: string[]; - evaluatedAt: Date; -} diff --git a/BackendAcademy/src/ai/placeholder.txt b/BackendAcademy/src/ai/placeholder.txt deleted file mode 100644 index bdd4ba3794..0000000000 --- a/BackendAcademy/src/ai/placeholder.txt +++ /dev/null @@ -1 +0,0 @@ -This is a placeholder to create the ai directory \ No newline at end of file diff --git a/BackendAcademy/src/ai/prompt-template.service.spec.ts b/BackendAcademy/src/ai/prompt-template.service.spec.ts deleted file mode 100644 index 06bf5e1376..0000000000 --- a/BackendAcademy/src/ai/prompt-template.service.spec.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { mkdtempSync, rmSync, writeFileSync } from 'fs'; -import { join, relative } from 'path'; -import { PromptTemplateService } from './prompt-template.service'; - -describe('PromptTemplateService reloads', () => { - let fixtureDirectory: string; - let configPath: string; - let configuredPath: string; - let service: PromptTemplateService; - - beforeEach(() => { - fixtureDirectory = mkdtempSync(join(process.cwd(), '.prompt-template-test-')); - configPath = join(fixtureDirectory, 'templates.json'); - configuredPath = relative(process.cwd(), configPath); - service = new PromptTemplateService({ get: jest.fn(() => configuredPath) } as any); - }); - - afterEach(() => { - rmSync(fixtureDirectory, { recursive: true, force: true }); - }); - - it('keeps the last valid templates when a later reload is malformed', () => { - writeFileSync(configPath, JSON.stringify({ - schemaVersion: '1.0.0', - templates: { chat_tutor: [{ version: '2.0.0', description: 'Test', systemPrompt: 'Use the reloaded prompt.', approval: { status: 'approved' } }] }, - })); - - expect(service.reloadTemplates()).toBe(true); - expect(service.getSystemPrompt('chat_tutor')).toBe('Use the reloaded prompt.'); - - writeFileSync(configPath, '{not valid JSON'); - expect(service.reloadTemplates()).toBe(false); - expect(service.getSystemPrompt('chat_tutor')).toBe('Use the reloaded prompt.'); - expect(service.getTemplateVersion('chat_tutor')).toBe('2.0.0'); - }); - - it('rejects a path that escapes the application directory', () => { - const traversalService = new PromptTemplateService({ get: jest.fn(() => '../templates.json') } as any); - - expect(traversalService.reloadTemplates()).toBe(false); - expect(traversalService.getTemplateVersion('chat_tutor')).toBe('1.0.0'); - }); - - it('does not activate a syntactically valid but structurally invalid config', () => { - writeFileSync(configPath, JSON.stringify({ - schemaVersion: '1.0.0', templates: { chat_tutor: [{ version: '2.0.0' }] }, - })); - - expect(service.reloadTemplates()).toBe(false); - expect(service.getTemplateVersion('chat_tutor')).toBe('1.0.0'); - }); -}); - -describe('PromptTemplateService deep schema validation (BA-083 / #651)', () => { - let fixtureDirectory: string; - let configPath: string; - let configuredPath: string; - let service: PromptTemplateService; - - const validTemplate = () => ({ - version: '2.0.0', - description: 'A valid description.', - systemPrompt: 'A valid system prompt.', - assistantRole: 'Tutor', - approval: { status: 'approved', approvedBy: 'tester', approvedAt: '2026-01-01T00:00:00.000Z' }, - }); - - const writeConfig = (config: unknown): boolean => { - writeFileSync(configPath, JSON.stringify(config)); - return service.reloadTemplates(); - }; - - beforeEach(() => { - fixtureDirectory = mkdtempSync(join(process.cwd(), '.prompt-template-test-')); - configPath = join(fixtureDirectory, 'templates.json'); - configuredPath = relative(process.cwd(), configPath); - service = new PromptTemplateService({ get: jest.fn(() => configuredPath) } as any); - }); - - afterEach(() => { - rmSync(fixtureDirectory, { recursive: true, force: true }); - }); - - it('activates a well-formed external config', () => { - const ok = writeConfig({ - schemaVersion: '1.2.3', - templates: { chat_tutor: [validTemplate()] }, - }); - expect(ok).toBe(true); - expect(service.getTemplateVersion('chat_tutor')).toBe('2.0.0'); - }); - - it('rejects an invalid schema version format', () => { - const ok = writeConfig({ - schemaVersion: 'v1.0', - templates: { chat_tutor: [validTemplate()] }, - }); - expect(ok).toBe(false); - expect(service.getTemplateVersion('chat_tutor')).toBe('1.0.0'); - }); - - it('rejects a malformed template name', () => { - const ok = writeConfig({ - schemaVersion: '1.0.0', - templates: { 'bad name!': [validTemplate()] }, - }); - expect(ok).toBe(false); - }); - - it('rejects an invalid template version format', () => { - const ok = writeConfig({ - schemaVersion: '1.0.0', - templates: { chat_tutor: [{ ...validTemplate(), version: 'latest' }] }, - }); - expect(ok).toBe(false); - }); - - it('rejects empty required text fields', () => { - const noDescription = writeConfig({ - schemaVersion: '1.0.0', - templates: { chat_tutor: [{ ...validTemplate(), description: ' ' }] }, - }); - expect(noDescription).toBe(false); - - const noPrompt = writeConfig({ - schemaVersion: '1.0.0', - templates: { chat_tutor: [{ ...validTemplate(), systemPrompt: '' }] }, - }); - expect(noPrompt).toBe(false); - }); - - it('rejects duplicate versions within a template array', () => { - const ok = writeConfig({ - schemaVersion: '1.0.0', - templates: { chat_tutor: [validTemplate(), { ...validTemplate() }] }, - }); - expect(ok).toBe(false); - }); - - it('rejects an invalid approval status', () => { - const ok = writeConfig({ - schemaVersion: '1.0.0', - templates: { - chat_tutor: [{ ...validTemplate(), approval: { status: 'maybe' } }], - }, - }); - expect(ok).toBe(false); - }); - - it('rejects array metadata', () => { - const ok = writeConfig({ - schemaVersion: '1.0.0', - templates: { chat_tutor: [{ ...validTemplate(), metadata: [] as any }] }, - }); - expect(ok).toBe(false); - }); -}); diff --git a/BackendAcademy/src/ai/prompt-template.service.ts b/BackendAcademy/src/ai/prompt-template.service.ts deleted file mode 100644 index a1d5c24537..0000000000 --- a/BackendAcademy/src/ai/prompt-template.service.ts +++ /dev/null @@ -1,581 +0,0 @@ -import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { readFileSync, existsSync } from 'fs'; -import { isAbsolute, relative, resolve, sep } from 'path'; - -/** - * Approval metadata recorded for a prompt template version. - * - * #653 (BA-085): prompt changes can affect learner safety and grading - * behaviour, so every version carries a review trail: who approved it, - * when, and any review notes. - */ -export interface PromptTemplateApproval { - status: 'approved' | 'pending' | 'rejected'; - approvedBy?: string; - approvedAt?: Date; - reviewNotes?: string; -} - -/** - * Rollback metadata recorded when an active template is rolled back. - * - * #653 (BA-085): the previous active version keeps a record of what it - * was rolled back from, by whom, when, and why. - */ -export interface PromptTemplateRollback { - /** Version that superseded / replaced this one via a rollback. */ - rolledBackFrom?: string; - rolledBackAt?: Date; - rolledBackBy?: string; - reason?: string; -} - -/** - * Represents a single prompt template with its version metadata. - * - * #374: Prompt templates are versioned so that changes can be - * audited, tested, and rolled out in a controlled manner. - * #653 (BA-085): each version additionally records its author, - * approval, effective time, and rollback metadata so prompt changes - * have a full governance trail. - */ -export interface PromptTemplate { - /** Semantic version of this template */ - version: string; - /** Human-readable description of the template's purpose */ - description: string; - /** The system prompt text */ - systemPrompt: string; - /** Optional role for the assistant */ - assistantRole?: string; - /** Author of this version (who created/modified it) — #653 */ - author?: string; - /** Approval trail for this version — #653 */ - approval?: PromptTemplateApproval; - /** Time from which this version is eligible to be active — #653 */ - effectiveAt?: Date; - /** Rollback trail — #653 */ - rollback?: PromptTemplateRollback; - /** Optional metadata about the template */ - metadata?: Record; -} - -/** - * A collection of prompt templates keyed by template name. - */ -interface PromptTemplateConfig { - /** Schema version for the config file itself */ - schemaVersion: string; - templates: Record; -} - -/** - * Default prompt templates used when no external configuration file - * is provided. These serve as the baseline v1.0.0 templates. - * - * #374: Templates are extracted from inline code into this versioned - * configuration so they can be audited and evolved independently. - */ -const DEFAULT_TEMPLATES: PromptTemplateConfig = { - schemaVersion: '1.0.0', - templates: { - chat_tutor: [ - { - version: '1.0.0', - description: 'Default Rust programming tutor persona for chat interactions.', - systemPrompt: - 'You are a helpful Rust programming tutor. Provide clear, concise explanations and encourage best practices. When reviewing code, point out potential improvements and explain the reasoning behind them.', - assistantRole: 'Rust Programming Tutor', - author: 'platform', - approval: { status: 'approved', approvedBy: 'platform', approvedAt: new Date('2026-01-01T00:00:00.000Z'), reviewNotes: 'Baseline v1 templates.' }, - effectiveAt: new Date('2026-01-01T00:00:00.000Z'), - }, - ], - code_review: [ - { - version: '1.0.0', - description: 'Code review assistant persona.', - systemPrompt: - 'You are a Rust code reviewer. Analyse the submitted code for correctness, safety, performance, and idiomatic Rust style. Suggest concrete improvements with examples.', - assistantRole: 'Rust Code Reviewer', - author: 'platform', - approval: { status: 'approved', approvedBy: 'platform', approvedAt: new Date('2026-01-01T00:00:00.000Z'), reviewNotes: 'Baseline v1 templates.' }, - effectiveAt: new Date('2026-01-01T00:00:00.000Z'), - }, - ], - hint_generator: [ - { - version: '1.0.0', - description: 'Progressive hint generator for coding challenges.', - systemPrompt: - 'You are a hint generator for Rust coding challenges. Provide hints at three difficulty levels: 1) gentle nudge, 2) more specific guidance, 3) near-solution. Never give the full answer directly.', - assistantRole: 'Hint Generator', - author: 'platform', - approval: { status: 'approved', approvedBy: 'platform', approvedAt: new Date('2026-01-01T00:00:00.000Z'), reviewNotes: 'Baseline v1 templates.' }, - effectiveAt: new Date('2026-01-01T00:00:00.000Z'), - }, - ], - fallback: [ - { - version: '1.0.0', - description: 'Fallback responses when AI provider is unavailable.', - systemPrompt: - 'You are a Rust Academy assistant operating in offline/fallback mode. Provide helpful but generic guidance since you cannot access the AI model at this time.', - assistantRole: 'Offline Assistant', - author: 'platform', - approval: { status: 'approved', approvedBy: 'platform', approvedAt: new Date('2026-01-01T00:00:00.000Z'), reviewNotes: 'Baseline v1 templates.' }, - effectiveAt: new Date('2026-01-01T00:00:00.000Z'), - }, - ], - }, -}; - -/** - * Semantic version pattern used to validate `schemaVersion` and each - * template `version` field. - * - * #651 (BA-083): externally supplied templates must declare a valid - * version before they can be activated. - */ -const SEMVER_REGEX = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-.]+)?(?:\+[0-9A-Za-z-.]+)?$/; - -/** - * Template name pattern. Names must start with a letter and contain only - * letters, digits, underscores, or hyphens — no spaces or other symbols. - * - * #651 (BA-083): malformed template names are rejected during validation. - */ -const TEMPLATE_NAME_REGEX = /^[a-zA-Z][a-zA-Z0-9_-]*$/; - -const APPROVAL_STATUSES = new Set(['approved', 'pending', 'rejected']); - -function isNonEmptyString(value: unknown): value is string { - return typeof value === 'string' && value.trim().length > 0; -} - -function isDateLike(value: unknown): boolean { - return ( - value instanceof Date || - (typeof value === 'string' && value.length > 0 && !isNaN(Date.parse(value))) - ); -} - -@Injectable() -export class PromptTemplateService implements OnModuleInit { - private readonly logger = new Logger(PromptTemplateService.name); - private templates: PromptTemplateConfig = DEFAULT_TEMPLATES; - - constructor(private readonly configService: ConfigService) {} - - onModuleInit(): void { - this.reloadTemplates(); - } - - /** - * Reloads prompt templates from the configured file path. Failed loads keep - * the currently active configuration, which is the built-in default set at startup. - * - * #374: Templates are loaded from a version-controlled config file - * so operators can update prompts without redeploying code. - */ - reloadTemplates(): boolean { - const templatePath = this.configService.get('AI_PROMPT_TEMPLATE_PATH'); - if (!templatePath) { - this.logger.log('No prompt template path configured; using built-in defaults'); - return false; - } - - const applicationRoot = resolve(process.cwd()); - const resolvedPath = resolve(applicationRoot, templatePath); - const pathFromApplicationRoot = relative(applicationRoot, resolvedPath); - if ( - pathFromApplicationRoot === '..' || - pathFromApplicationRoot.startsWith(`..${sep}`) || - isAbsolute(pathFromApplicationRoot) - ) { - this.logger.warn('Prompt template path must be within the application directory'); - return false; - } - if (!existsSync(resolvedPath)) { - this.logger.warn( - `Prompt template file not found at ${resolvedPath}; keeping active templates`, - ); - return false; - } - - try { - const raw = readFileSync(resolvedPath, 'utf-8'); - const parsed = JSON.parse(raw) as PromptTemplateConfig; - - if (!this.isValidConfig(parsed)) { - throw new Error('Invalid prompt template config'); - } - - // Construct the complete candidate before replacing the active configuration. - const nextTemplates: PromptTemplateConfig = { - schemaVersion: parsed.schemaVersion, - templates: { - ...DEFAULT_TEMPLATES.templates, - ...parsed.templates, - }, - }; - this.templates = nextTemplates; - - this.logger.log( - `Loaded prompt templates from ${resolvedPath} (schema v${parsed.schemaVersion})`, - ); - return true; - } catch (err) { - this.logger.error( - `Failed to load prompt templates from ${resolvedPath}: ${(err as Error).message}`, - ); - this.logger.warn('Keeping the previously active prompt templates'); - return false; - } - } - - /** - * Validates the full external prompt-template configuration before it is - * allowed to replace the active configuration. - * - * #651 (BA-083): validation is performed deeply, covering the schema - * version, every template name, every version's format, required text - * fields, and the contents of each version array (including nested - * approval/rollback metadata). - */ - private isValidConfig(config: PromptTemplateConfig): boolean { - if ( - !config || - typeof config !== 'object' || - Array.isArray(config) || - typeof config.schemaVersion !== 'string' || - !SEMVER_REGEX.test(config.schemaVersion) || - !config.templates || - typeof config.templates !== 'object' || - Array.isArray(config.templates) - ) { - return false; - } - - for (const [name, versions] of Object.entries(config.templates)) { - if (!TEMPLATE_NAME_REGEX.test(name)) { - this.logger.warn(`Rejecting prompt template with invalid name: "${name}"`); - return false; - } - - if (!Array.isArray(versions) || versions.length === 0) { - this.logger.warn(`Rejecting template "${name}": versions must be a non-empty array`); - return false; - } - - const seenVersions = new Set(); - for (const version of versions) { - if (!this.isValidTemplate(version)) { - this.logger.warn(`Rejecting malformed version entry for template "${name}"`); - return false; - } - - if (seenVersions.has(version.version)) { - this.logger.warn( - `Rejecting template "${name}": duplicate version "${version.version}"`, - ); - return false; - } - seenVersions.add(version.version); - } - } - - return true; - } - - /** - * Validates a single prompt-template version entry, including the required - * text fields and any nested approval/rollback metadata. - * - * #651 (BA-083): `version` must be semver, `description` and `systemPrompt` - * must be non-empty, and optional metadata must be a plain object. - */ - private isValidTemplate(template: unknown): template is PromptTemplate { - if (!template || typeof template !== 'object' || Array.isArray(template)) { - return false; - } - const t = template as Record; - - if (typeof t.version !== 'string' || !SEMVER_REGEX.test(t.version)) { - return false; - } - if (!isNonEmptyString(t.description)) { - return false; - } - if (!isNonEmptyString(t.systemPrompt)) { - return false; - } - if (t.assistantRole !== undefined && !isNonEmptyString(t.assistantRole)) { - return false; - } - if (t.author !== undefined && typeof t.author !== 'string') { - return false; - } - if (t.effectiveAt !== undefined && !isDateLike(t.effectiveAt)) { - return false; - } - if (!this.isValidApproval(t.approval)) { - return false; - } - if (!this.isValidRollback(t.rollback)) { - return false; - } - if ( - t.metadata !== undefined && - (typeof t.metadata !== 'object' || t.metadata === null || Array.isArray(t.metadata)) - ) { - return false; - } - return true; - } - - /** - * Validates optional approval metadata on a template version. - * - * #651 (BA-083): when present, the status must be a recognised value and - * any supplied fields must be of the correct type. - */ - private isValidApproval(approval: unknown): boolean { - if (approval === undefined) return true; - if (!approval || typeof approval !== 'object' || Array.isArray(approval)) { - return false; - } - const a = approval as Record; - if ( - typeof a.status !== 'string' || - !APPROVAL_STATUSES.has(a.status) - ) { - return false; - } - if (a.approvedBy !== undefined && !isNonEmptyString(a.approvedBy)) { - return false; - } - if (a.reviewNotes !== undefined && typeof a.reviewNotes !== 'string') { - return false; - } - if (a.approvedAt !== undefined && !isDateLike(a.approvedAt)) { - return false; - } - return true; - } - - /** - * Validates optional rollback metadata on a template version. - * - * #651 (BA-083): when present, any supplied fields must be of the correct - * type. - */ - private isValidRollback(rollback: unknown): boolean { - if (rollback === undefined) return true; - if (!rollback || typeof rollback !== 'object' || Array.isArray(rollback)) { - return false; - } - const r = rollback as Record; - if (r.rolledBackFrom !== undefined && !isNonEmptyString(r.rolledBackFrom)) { - return false; - } - if (r.rolledBackBy !== undefined && !isNonEmptyString(r.rolledBackBy)) { - return false; - } - if (r.reason !== undefined && typeof r.reason !== 'string') { - return false; - } - if (r.rolledBackAt !== undefined && !isDateLike(r.rolledBackAt)) { - return false; - } - return true; - } - - /** - * Retrieves the system prompt for a given template name and optional - * version constraint. - * - * @param templateName - The name of the template (e.g., 'chat_tutor') - * @param options - Optional version and metadata overrides - * @returns The system prompt string - * - * #374: Callers specify a template name and optionally a version. - * If the requested version doesn't exist, the latest available version - * is returned with a warning logged. - */ - getSystemPrompt( - templateName: string, - options?: { version?: string; metadata?: Record }, - ): string { - const active = this.getActiveTemplate(templateName, options?.version); - if (!active) { - this.logger.warn( - `No active template for "${templateName}"; returning generic fallback`, - ); - return DEFAULT_TEMPLATES.templates.fallback[0].systemPrompt; - } - this.logger.debug( - `Using prompt template "${templateName}" v${active.version} (${active.author ?? 'unknown'} / ${active.approval?.status ?? 'pending'})`, - ); - return active.systemPrompt; - } - - /** - * Returns the currently active template version for a given template name. - * - * #653 (BA-085): a version is active only when it is approved and its - * effective time has been reached, and it has not been rolled back. If a - * specific version is requested it is honoured when it satisfies those - * constraints; otherwise the latest eligible version wins. - */ - getActiveTemplate( - templateName: string, - version?: string, - ): PromptTemplate | null { - const versions = this.templates.templates[templateName]; - if (!versions || versions.length === 0) return null; - const now = new Date(); - - const eligible = versions.filter( - (v) => - v.approval?.status === 'approved' && - !v.rollback && - (!v.effectiveAt || v.effectiveAt <= now), - ); - if (eligible.length === 0) return null; - - if (version) { - const match = eligible.find((v) => v.version === version); - if (match) return match; - this.logger.warn( - `Version ${version} not active for template "${templateName}"; using latest eligible`, - ); - } - - return eligible[eligible.length - 1]; - } - - /** - * Returns the current active template version string for a template name. - */ - getTemplateVersion(templateName: string): string | null { - return this.getActiveTemplate(templateName)?.version ?? null; - } - - /** - * Records approval metadata for a specific template version. - * - * #653 (BA-085): approving a version writes the approver, timestamp, and - * review notes onto the version so the approval trail is inspectable. - */ - approveTemplate( - templateName: string, - version: string, - approvedBy: string, - reviewNotes?: string, - ): PromptTemplate | null { - const target = this.findTemplate(templateName, version); - if (!target) return null; - target.approval = { - status: 'approved', - approvedBy, - approvedAt: new Date(), - reviewNotes, - }; - this.logger.log( - `Prompt template "${templateName}" v${version} approved by ${approvedBy}`, - ); - return target; - } - - /** - * Records rollback metadata on the currently active version and marks the - * previous eligible version as active again. - * - * #653 (BA-085): the superseded version keeps a record of what it was - * rolled back from, by whom, when, and why. - */ - rollbackTemplate( - templateName: string, - rolledBackBy: string, - reason?: string, - ): PromptTemplate | null { - const active = this.getActiveTemplate(templateName); - if (!active) return null; - - active.rollback = { - rolledBackFrom: this.getPreviousEligibleVersion(templateName, active.version) ?? undefined, - rolledBackAt: new Date(), - rolledBackBy, - reason, - }; - this.logger.warn( - `Prompt template "${templateName}" v${active.version} rolled back by ${rolledBackBy}${reason ? `: ${reason}` : ''}`, - ); - return active; - } - - /** - * Returns the audit trail for a template: every version with its author, - * approval, effective time, and rollback metadata. - */ - getTemplateAuditTrail( - templateName: string, - ): Array<{ - version: string; - description: string; - author?: string; - approval?: PromptTemplateApproval; - effectiveAt?: Date; - rollback?: PromptTemplateRollback; - }> { - const versions = this.templates.templates[templateName] ?? []; - return versions.map(({ version, description, author, approval, effectiveAt, rollback }) => ({ - version, - description, - author, - approval, - effectiveAt, - rollback, - })); - } - - /** - * Returns all available template names and their versions. - * - * #374: Enables auditing of which templates are available and their versions. - */ - listTemplates(): Array<{ name: string; versions: string[] }> { - return Object.entries(this.templates.templates).map(([name, versions]) => ({ - name, - versions: versions.map((v) => v.version), - })); - } - - private findTemplate(templateName: string, version: string): PromptTemplate | null { - const versions = this.templates.templates[templateName]; - return versions?.find((v) => v.version === version) ?? null; - } - - private getPreviousEligibleVersion( - templateName: string, - currentVersion: string, - ): string | null { - const versions = this.templates.templates[templateName] ?? []; - const index = versions.findIndex((v) => v.version === currentVersion); - if (index <= 0) return null; - const now = new Date(); - for (let i = index - 1; i >= 0; i--) { - const v = versions[i]; - if ( - v.approval?.status === 'approved' && - !v.rollback && - (!v.effectiveAt || v.effectiveAt <= now) - ) { - return v.version; - } - } - return null; - } -} diff --git a/BackendAcademy/src/ai/providers/claude.provider.ts b/BackendAcademy/src/ai/providers/claude.provider.ts deleted file mode 100644 index 3a0dd4d1f7..0000000000 --- a/BackendAcademy/src/ai/providers/claude.provider.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import axios from 'axios'; -import { - AiProvider, - AiProviderError, - AiProviderErrorCode, - AiRetryPolicy, - ChatCompletionOptions, - DEFAULT_AI_RETRY_POLICY, - ProviderChatResult, -} from '../interfaces/ai-provider.interface'; -import { withRetry } from './retry'; - -/** - * Anthropic Claude Messages provider. - * - * BA-079: the vendor response is normalized into {@link ProviderChatResult} - * and malformed/empty payloads are surfaced as stable {@link AiProviderError} - * domain errors instead of silently returning empty strings. - * - * BA-078: transient failures (429, 5xx, network) are retried with bounded - * exponential backoff via {@link withRetry}; non-retryable errors propagate - * with sanitized messages. - */ -@Injectable() -export class ClaudeProvider implements AiProvider { - private apiKey: string; - private model: string; - private maxTokens: number; - private temperature: number; - private retryPolicy: AiRetryPolicy; - - constructor(private configService: ConfigService) { - this.apiKey = this.configService.get('ANTHROPIC_API_KEY') || ''; - this.model = this.configService.get('AI_MODEL') || 'claude-sonnet-4-20250514'; - this.maxTokens = this.configService.get('AI_MAX_TOKENS') || 4096; - this.temperature = this.configService.get('AI_TEMPERATURE') || 0.7; - this.retryPolicy = { - maxAttempts: - this.configService.get('AI_RETRY_MAX_ATTEMPTS') ?? - DEFAULT_AI_RETRY_POLICY.maxAttempts, - baseDelayMs: - this.configService.get('AI_RETRY_BASE_DELAY_MS') ?? - DEFAULT_AI_RETRY_POLICY.baseDelayMs, - maxDelayMs: - this.configService.get('AI_RETRY_MAX_DELAY_MS') ?? - DEFAULT_AI_RETRY_POLICY.maxDelayMs, - }; - } - - async generateChatCompletion( - options: ChatCompletionOptions, - retryPolicy?: AiRetryPolicy, - ): Promise { - if (!this.apiKey) { - throw new AiProviderError( - AiProviderErrorCode.MISSING_CREDENTIALS, - 'Claude provider selected but ANTHROPIC_API_KEY is not set', - { retryable: false }, - ); - } - - const policy = retryPolicy ?? this.retryPolicy; - const systemMsg = options.messages.find((m) => m.role === 'system'); - const otherMessages = options.messages.filter((m) => m.role !== 'system'); - - const data = await withRetry( - async () => { - const { data } = await axios.post( - 'https://api.anthropic.com/v1/messages', - { - model: this.model, - max_tokens: options.maxTokens ?? this.maxTokens, - temperature: options.temperature ?? this.temperature, - system: systemMsg?.content, - messages: otherMessages.map((m) => ({ - role: m.role === 'assistant' ? 'assistant' : 'user', - content: m.content, - })), - }, - { - headers: { - 'x-api-key': this.apiKey, - 'anthropic-version': '2023-06-01', - 'content-type': 'application/json', - }, - }, - ); - return data; - }, - policy, - undefined, - 'Claude', - ); - - return this.normalizeResponse(data); - } - - /** - * Map the raw Anthropic payload to the shared result model (BA-079). - * - * @throws AiProviderError(MALFORMED_RESPONSE) when the payload shape is - * not the documented Messages schema. - * @throws AiProviderError(EMPTY_RESPONSE) when the payload is well-formed - * but carries no usable completion text. - */ - private normalizeResponse(data: unknown): ProviderChatResult { - const body = data as { - model?: string; - content?: Array<{ type?: string; text?: string }>; - usage?: { input_tokens?: number; output_tokens?: number }; - }; - - if (!Array.isArray(body?.content)) { - throw new AiProviderError( - AiProviderErrorCode.MALFORMED_RESPONSE, - 'Claude provider returned a malformed response (missing content array)', - { retryable: false }, - ); - } - - const textBlock = body.content.find((block) => block?.type === 'text'); - - if (!textBlock || typeof textBlock.text !== 'string') { - throw new AiProviderError( - AiProviderErrorCode.MALFORMED_RESPONSE, - 'Claude provider returned a malformed response (no text block)', - { retryable: false }, - ); - } - - if (textBlock.text.trim().length === 0) { - throw new AiProviderError( - AiProviderErrorCode.EMPTY_RESPONSE, - 'Claude provider returned an empty completion', - { retryable: false }, - ); - } - - return { - content: textBlock.text, - provider: 'claude', - model: body.model, - usage: body.usage - ? { - promptTokens: body.usage.input_tokens, - completionTokens: body.usage.output_tokens, - totalTokens: (body.usage.input_tokens ?? 0) + (body.usage.output_tokens ?? 0), - } - : undefined, - }; - } -} diff --git a/BackendAcademy/src/ai/providers/index.ts b/BackendAcademy/src/ai/providers/index.ts deleted file mode 100644 index 0adf0ce8b9..0000000000 --- a/BackendAcademy/src/ai/providers/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { ClaudeProvider } from './claude.provider'; -export { OpenaiProvider } from './openai.provider'; diff --git a/BackendAcademy/src/ai/providers/openai.provider.ts b/BackendAcademy/src/ai/providers/openai.provider.ts deleted file mode 100644 index 2f0069812e..0000000000 --- a/BackendAcademy/src/ai/providers/openai.provider.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import axios from 'axios'; -import { - AiProvider, - AiProviderError, - AiProviderErrorCode, - AiRetryPolicy, - ChatCompletionOptions, - DEFAULT_AI_RETRY_POLICY, - ProviderChatResult, -} from '../interfaces/ai-provider.interface'; -import { withRetry } from './retry'; - -/** - * OpenAI chat-completions provider. - * - * BA-079: the vendor response is normalized into {@link ProviderChatResult} - * and malformed/empty payloads are surfaced as stable {@link AiProviderError} - * domain errors instead of silently returning empty strings. - * - * BA-078: transient failures (429, 5xx, network) are retried with bounded - * exponential backoff via {@link withRetry}; non-retryable errors propagate - * with sanitized messages. - */ -@Injectable() -export class OpenaiProvider implements AiProvider { - private apiKey: string; - private model: string; - private maxTokens: number; - private temperature: number; - private retryPolicy: AiRetryPolicy; - - constructor(private configService: ConfigService) { - this.apiKey = this.configService.get('OPENAI_API_KEY') || ''; - this.model = this.configService.get('AI_MODEL') || 'gpt-4o'; - this.maxTokens = this.configService.get('AI_MAX_TOKENS') || 4096; - this.temperature = this.configService.get('AI_TEMPERATURE') || 0.7; - this.retryPolicy = { - maxAttempts: - this.configService.get('AI_RETRY_MAX_ATTEMPTS') ?? - DEFAULT_AI_RETRY_POLICY.maxAttempts, - baseDelayMs: - this.configService.get('AI_RETRY_BASE_DELAY_MS') ?? - DEFAULT_AI_RETRY_POLICY.baseDelayMs, - maxDelayMs: - this.configService.get('AI_RETRY_MAX_DELAY_MS') ?? - DEFAULT_AI_RETRY_POLICY.maxDelayMs, - }; - } - - async generateChatCompletion( - options: ChatCompletionOptions, - retryPolicy?: AiRetryPolicy, - ): Promise { - if (!this.apiKey) { - throw new AiProviderError( - AiProviderErrorCode.MISSING_CREDENTIALS, - 'OpenAI provider selected but OPENAI_API_KEY is not set', - { retryable: false }, - ); - } - - const policy = retryPolicy ?? this.retryPolicy; - - const data = await withRetry( - async () => { - const { data } = await axios.post( - 'https://api.openai.com/v1/chat/completions', - { - model: this.model, - max_tokens: options.maxTokens ?? this.maxTokens, - temperature: options.temperature ?? this.temperature, - messages: options.messages.map((m) => ({ - role: m.role, - content: m.content, - })), - }, - { - headers: { - Authorization: `Bearer ${this.apiKey}`, - 'content-type': 'application/json', - }, - }, - ); - return data; - }, - policy, - undefined, - 'OpenAI', - ); - - return this.normalizeResponse(data); - } - - /** - * Map the raw OpenAI payload to the shared result model (BA-079). - * - * @throws AiProviderError(MALFORMED_RESPONSE) when the payload shape is - * not the documented chat-completions schema. - * @throws AiProviderError(EMPTY_RESPONSE) when the payload is well-formed - * but carries no usable completion text. - */ - private normalizeResponse(data: unknown): ProviderChatResult { - const body = data as { - model?: string; - choices?: Array<{ message?: { content?: string } }>; - usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number }; - }; - - if (!Array.isArray(body?.choices)) { - throw new AiProviderError( - AiProviderErrorCode.MALFORMED_RESPONSE, - 'OpenAI provider returned a malformed response (missing choices array)', - { retryable: false }, - ); - } - - if (body.choices.length === 0) { - throw new AiProviderError( - AiProviderErrorCode.EMPTY_RESPONSE, - 'OpenAI provider returned no choices', - { retryable: false }, - ); - } - - const content = body.choices[0]?.message?.content; - - if (typeof content !== 'string') { - throw new AiProviderError( - AiProviderErrorCode.MALFORMED_RESPONSE, - 'OpenAI provider returned a malformed response (missing message content)', - { retryable: false }, - ); - } - - if (content.trim().length === 0) { - throw new AiProviderError( - AiProviderErrorCode.EMPTY_RESPONSE, - 'OpenAI provider returned an empty completion', - { retryable: false }, - ); - } - - return { - content, - provider: 'openai', - model: body.model, - usage: body.usage - ? { - promptTokens: body.usage.prompt_tokens, - completionTokens: body.usage.completion_tokens, - totalTokens: body.usage.total_tokens, - } - : undefined, - }; - } -} diff --git a/BackendAcademy/src/ai/providers/providers.spec.ts b/BackendAcademy/src/ai/providers/providers.spec.ts deleted file mode 100644 index be2124f539..0000000000 --- a/BackendAcademy/src/ai/providers/providers.spec.ts +++ /dev/null @@ -1,279 +0,0 @@ -import { ConfigService } from '@nestjs/config'; -import axios from 'axios'; -import { OpenaiProvider } from './openai.provider'; -import { ClaudeProvider } from './claude.provider'; -import { - AiProviderError, - AiProviderErrorCode, - ProviderChatResult, -} from '../interfaces/ai-provider.interface'; - -jest.mock('axios'); -const mockedAxios = axios as jest.Mocked; - -function configService(values: Record): ConfigService { - return { get: (key: string): T | undefined => values[key] as T } as ConfigService; -} - -/** Retry policy with zero delay so tests stay fast. */ -const FAST_POLICY = { maxAttempts: 2, baseDelayMs: 0, maxDelayMs: 0 }; - -const OPENAI_CFG = configService({ - OPENAI_API_KEY: 'sk-test-openai', - AI_MODEL: 'gpt-4o', - AI_MAX_TOKENS: 100, - AI_TEMPERATURE: 0.5, -}); - -const CLAUDE_CFG = configService({ - ANTHROPIC_API_KEY: 'sk-ant-test-claude', - AI_MODEL: 'claude-sonnet-4-20250514', - AI_MAX_TOKENS: 100, - AI_TEMPERATURE: 0.5, -}); - -describe('AI providers — BA-079 response normalization & BA-078 retry', () => { - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('OpenaiProvider', () => { - it('normalizes a valid response into the shared result model', async () => { - mockedAxios.post.mockResolvedValueOnce({ - data: { - model: 'gpt-4o', - choices: [{ message: { content: 'Ownership is key.' } }], - usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, - }, - }); - - const provider = new OpenaiProvider(OPENAI_CFG); - const result: ProviderChatResult = await provider.generateChatCompletion({ - messages: [{ role: 'user', content: 'Explain ownership' }], - }); - - expect(result).toEqual({ - content: 'Ownership is key.', - provider: 'openai', - model: 'gpt-4o', - usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 }, - }); - }); - - it('throws a stable MALFORMED_RESPONSE error when choices is missing', async () => { - mockedAxios.post.mockResolvedValueOnce({ data: { model: 'gpt-4o' } }); - const provider = new OpenaiProvider(OPENAI_CFG); - - await expect( - provider.generateChatCompletion({ messages: [{ role: 'user', content: 'hi' }] }), - ).rejects.toMatchObject({ - name: 'AiProviderError', - code: AiProviderErrorCode.MALFORMED_RESPONSE, - retryable: false, - }); - }); - - it('throws EMPTY_RESPONSE when choices is an empty array', async () => { - mockedAxios.post.mockResolvedValueOnce({ data: { choices: [] } }); - const provider = new OpenaiProvider(OPENAI_CFG); - - await expect( - provider.generateChatCompletion({ messages: [{ role: 'user', content: 'hi' }] }), - ).rejects.toMatchObject({ code: AiProviderErrorCode.EMPTY_RESPONSE }); - }); - - it('throws MALFORMED_RESPONSE when message content is missing', async () => { - mockedAxios.post.mockResolvedValueOnce({ - data: { choices: [{ message: {} }] }, - }); - const provider = new OpenaiProvider(OPENAI_CFG); - - await expect( - provider.generateChatCompletion({ messages: [{ role: 'user', content: 'hi' }] }), - ).rejects.toMatchObject({ code: AiProviderErrorCode.MALFORMED_RESPONSE }); - }); - - it('throws EMPTY_RESPONSE when content is an empty string', async () => { - mockedAxios.post.mockResolvedValueOnce({ - data: { choices: [{ message: { content: ' ' } }] }, - }); - const provider = new OpenaiProvider(OPENAI_CFG); - - await expect( - provider.generateChatCompletion({ messages: [{ role: 'user', content: 'hi' }] }), - ).rejects.toMatchObject({ code: AiProviderErrorCode.EMPTY_RESPONSE }); - }); - - it('throws MISSING_CREDENTIALS without calling the API when the key is unset', async () => { - const provider = new OpenaiProvider(configService({ OPENAI_API_KEY: '' })); - - await expect( - provider.generateChatCompletion({ messages: [{ role: 'user', content: 'hi' }] }), - ).rejects.toMatchObject({ - code: AiProviderErrorCode.MISSING_CREDENTIALS, - retryable: false, - }); - expect(mockedAxios.post).not.toHaveBeenCalled(); - }); - - it('retries a 429 response and succeeds on the next attempt', async () => { - mockedAxios.post - .mockRejectedValueOnce({ response: { status: 429, data: {} } }) - .mockResolvedValueOnce({ - data: { choices: [{ message: { content: 'recovered' } }] }, - }); - - const provider = new OpenaiProvider(OPENAI_CFG); - const result = await provider.generateChatCompletion( - { messages: [{ role: 'user', content: 'hi' }] }, - FAST_POLICY, - ); - - expect(result.content).toBe('recovered'); - expect(mockedAxios.post).toHaveBeenCalledTimes(2); - }); - - it('gives up after bounded attempts and surfaces RATE_LIMITED', async () => { - mockedAxios.post.mockRejectedValue({ response: { status: 429, data: {} } }); - - const provider = new OpenaiProvider(OPENAI_CFG); - const error = await provider - .generateChatCompletion({ messages: [{ role: 'user', content: 'hi' }] }, FAST_POLICY) - .catch((e) => e); - - expect(error).toBeInstanceOf(AiProviderError); - expect(error.code).toBe(AiProviderErrorCode.RATE_LIMITED); - expect(error.retryable).toBe(true); - expect(error.attempts).toBe(2); - // 1 initial attempt + 2 retries - expect(mockedAxios.post).toHaveBeenCalledTimes(3); - }); - - it('does not retry non-retryable 4xx errors', async () => { - mockedAxios.post.mockRejectedValue({ response: { status: 400, data: {} } }); - - const provider = new OpenaiProvider(OPENAI_CFG); - const error = await provider - .generateChatCompletion({ messages: [{ role: 'user', content: 'hi' }] }, FAST_POLICY) - .catch((e) => e); - - expect(error).toBeInstanceOf(AiProviderError); - expect(error.retryable).toBe(false); - expect(error.status).toBe(400); - expect(mockedAxios.post).toHaveBeenCalledTimes(1); - }); - - it('retries network-level failures (no response)', async () => { - mockedAxios.post - .mockRejectedValueOnce({ request: {}, message: 'ECONNRESET' }) - .mockResolvedValueOnce({ - data: { choices: [{ message: { content: 'back online' } }] }, - }); - - const provider = new OpenaiProvider(OPENAI_CFG); - const result = await provider.generateChatCompletion( - { messages: [{ role: 'user', content: 'hi' }] }, - FAST_POLICY, - ); - - expect(result.content).toBe('back online'); - expect(mockedAxios.post).toHaveBeenCalledTimes(2); - }); - }); - - describe('ClaudeProvider', () => { - it('normalizes a valid response into the shared result model', async () => { - mockedAxios.post.mockResolvedValueOnce({ - data: { - model: 'claude-sonnet-4-20250514', - content: [{ type: 'text', text: 'Borrowing rules…' }], - usage: { input_tokens: 8, output_tokens: 4 }, - }, - }); - - const provider = new ClaudeProvider(CLAUDE_CFG); - const result: ProviderChatResult = await provider.generateChatCompletion({ - messages: [{ role: 'user', content: 'Explain borrowing' }], - }); - - expect(result).toEqual({ - content: 'Borrowing rules…', - provider: 'claude', - model: 'claude-sonnet-4-20250514', - usage: { promptTokens: 8, completionTokens: 4, totalTokens: 12 }, - }); - }); - - it('throws MALFORMED_RESPONSE when content array is missing', async () => { - mockedAxios.post.mockResolvedValueOnce({ data: { model: 'claude' } }); - const provider = new ClaudeProvider(CLAUDE_CFG); - - await expect( - provider.generateChatCompletion({ messages: [{ role: 'user', content: 'hi' }] }), - ).rejects.toMatchObject({ code: AiProviderErrorCode.MALFORMED_RESPONSE }); - }); - - it('throws MALFORMED_RESPONSE when no text block exists', async () => { - mockedAxios.post.mockResolvedValueOnce({ - data: { content: [{ type: 'tool_use', text: '' }] }, - }); - const provider = new ClaudeProvider(CLAUDE_CFG); - - await expect( - provider.generateChatCompletion({ messages: [{ role: 'user', content: 'hi' }] }), - ).rejects.toMatchObject({ code: AiProviderErrorCode.MALFORMED_RESPONSE }); - }); - - it('throws EMPTY_RESPONSE when the text block is empty', async () => { - mockedAxios.post.mockResolvedValueOnce({ - data: { content: [{ type: 'text', text: ' ' }] }, - }); - const provider = new ClaudeProvider(CLAUDE_CFG); - - await expect( - provider.generateChatCompletion({ messages: [{ role: 'user', content: 'hi' }] }), - ).rejects.toMatchObject({ code: AiProviderErrorCode.EMPTY_RESPONSE }); - }); - - it('throws MISSING_CREDENTIALS without calling the API when the key is unset', async () => { - const provider = new ClaudeProvider(configService({ ANTHROPIC_API_KEY: '' })); - - await expect( - provider.generateChatCompletion({ messages: [{ role: 'user', content: 'hi' }] }), - ).rejects.toMatchObject({ code: AiProviderErrorCode.MISSING_CREDENTIALS }); - expect(mockedAxios.post).not.toHaveBeenCalled(); - }); - - it('retries a 503 and succeeds on the next attempt', async () => { - mockedAxios.post - .mockRejectedValueOnce({ response: { status: 503, data: {} } }) - .mockResolvedValueOnce({ - data: { content: [{ type: 'text', text: 'degraded but ok' }] }, - }); - - const provider = new ClaudeProvider(CLAUDE_CFG); - const result = await provider.generateChatCompletion( - { messages: [{ role: 'user', content: 'hi' }] }, - FAST_POLICY, - ); - - expect(result.content).toBe('degraded but ok'); - expect(mockedAxios.post).toHaveBeenCalledTimes(2); - }); - - it('sanitizes provider error messages (no payload details)', async () => { - mockedAxios.post.mockRejectedValue({ - response: { status: 500, data: { error: { message: 'sk-secret-token-leak' } } }, - }); - - const provider = new ClaudeProvider(CLAUDE_CFG); - const error = await provider - .generateChatCompletion({ messages: [{ role: 'user', content: 'hi' }] }, FAST_POLICY) - .catch((e) => e); - - expect(error.message).not.toContain('sk-secret-token-leak'); - expect(error.message).toContain('Claude'); - expect(error.message).toContain('500'); - }); - }); -}); diff --git a/BackendAcademy/src/ai/providers/retry.ts b/BackendAcademy/src/ai/providers/retry.ts deleted file mode 100644 index ac1ceb8f20..0000000000 --- a/BackendAcademy/src/ai/providers/retry.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { - AiProviderError, - AiProviderErrorCode, - AiRetryPolicy, - DEFAULT_AI_RETRY_POLICY, -} from '../interfaces/ai-provider.interface'; - -export type SleepFn = (ms: number) => Promise; - -const defaultSleep: SleepFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - -/** - * Classify an unknown thrown value into an {@link AiProviderError}. - * - * BA-078: transient failures (HTTP 429, 5xx, or network-level errors) are - * marked retryable; everything else (4xx, malformed payloads, credential - * issues) is not. The returned error message never includes raw vendor - * payloads or credentials. - */ -export function classifyProviderError( - err: unknown, - provider: string, -): AiProviderError { - if (err instanceof AiProviderError) { - return err; - } - - // Axios-style errors carry an optional HTTP response. - const axiosLike = err as { - response?: { status?: number; data?: unknown }; - request?: unknown; - message?: string; - }; - - if (axiosLike?.response) { - const status = axiosLike.response.status; - if (status === 429) { - return new AiProviderError( - AiProviderErrorCode.RATE_LIMITED, - `${provider} provider rate limited the request (HTTP 429)`, - { status, retryable: true }, - ); - } - if (status !== undefined && status >= 500) { - return new AiProviderError( - AiProviderErrorCode.UPSTREAM_ERROR, - `${provider} provider returned an upstream error (HTTP ${status})`, - { status, retryable: true }, - ); - } - // 4xx responses are configuration/request errors — do not retry. - return new AiProviderError( - AiProviderErrorCode.UPSTREAM_ERROR, - `${provider} provider rejected the request (HTTP ${status ?? 'unknown'})`, - { status, retryable: false }, - ); - } - - // No HTTP response: the request never reached the provider (DNS, TLS, - // connection reset, …). These are transient and worth retrying. - if (axiosLike?.request !== undefined || axiosLike?.message) { - return new AiProviderError( - AiProviderErrorCode.UPSTREAM_ERROR, - `${provider} provider is unreachable (network error)`, - { retryable: true }, - ); - } - - return new AiProviderError( - AiProviderErrorCode.UPSTREAM_ERROR, - `${provider} provider call failed`, - { retryable: false }, - ); -} - -/** - * Compute the backoff delay for attempt `attempt` (1-based), with full - * jitter so concurrent retries don't stampede the vendor. - */ -export function backoffDelay( - attempt: number, - policy: AiRetryPolicy = DEFAULT_AI_RETRY_POLICY, -): number { - const exponential = policy.baseDelayMs * 2 ** (attempt - 1); - const capped = Math.min(exponential, policy.maxDelayMs); - // Full jitter: random value in [0, capped) keeps retries spread out. - return Math.floor(Math.random() * capped); -} - -/** - * Run `fn` with a bounded retry policy (BA-078). - * - * - Only retryable failures ({@link AiProviderError.retryable}) are retried. - * - The number of attempts is capped by `policy.maxAttempts`. - * - Delay between attempts uses exponential backoff with jitter, capped at - * `policy.maxDelayMs`. - * - The final error is re-thrown with the consumed attempt count attached; - * its message stays sanitized (no provider internals). - */ -export async function withRetry( - fn: () => Promise, - policy: AiRetryPolicy = DEFAULT_AI_RETRY_POLICY, - sleep: SleepFn = defaultSleep, - provider: string = 'AI', -): Promise { - let attempt = 0; - - // Validate the policy so misconfiguration fails fast instead of looping. - const maxAttempts = Math.max(0, Math.floor(policy.maxAttempts)); - const baseDelayMs = Math.max(0, policy.baseDelayMs); - const maxDelayMs = Math.max(0, policy.maxDelayMs); - - for (;;) { - try { - return await fn(); - } catch (err) { - const error = classifyProviderError(err, provider); - const canRetry = error.retryable && attempt < maxAttempts; - if (!canRetry) { - throw new AiProviderError(error.code, error.message, { - status: error.status, - retryable: error.retryable, - attempts: error.attempts ?? attempt, - }); - } - attempt += 1; - const delay = backoffDelay(attempt, { - maxAttempts, - baseDelayMs, - maxDelayMs, - }); - await sleep(delay); - } - } -} diff --git a/BackendAcademy/src/analytics/analytics.controller.ts b/BackendAcademy/src/analytics/analytics.controller.ts deleted file mode 100644 index db66140af6..0000000000 --- a/BackendAcademy/src/analytics/analytics.controller.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { - Controller, - Get, - Post, - Delete, - Body, - Param, - Query, - ParseIntPipe, - UsePipes, -} from '@nestjs/common'; -import { AnalyticsService, EventType } from './analytics.service'; -import { AnalyticsEvent } from './analytics.entity'; -import { CreateEventDto } from './dto/create-event.dto'; -import { ValidationPipe, ValidationSchema } from '../common/validation.middleware'; - -const analyticsEventSchema: ValidationSchema = { - required: ['eventType'], - properties: { - eventType: { type: 'string', enum: Object.values(EventType) }, - userId: { type: 'string' }, - sessionId: { type: 'string' }, - properties: { type: 'object' }, - ipAddress: { type: 'string' }, - userAgent: { type: 'string' }, - }, -}; - -@Controller('analytics') -export class AnalyticsController { - constructor(private readonly analyticsService: AnalyticsService) {} - - @Post('track') - @UsePipes(new ValidationPipe(analyticsEventSchema)) - async trackEvent(@Body() dto: CreateEventDto): Promise { - return this.analyticsService.trackEvent(dto); - } - - @Get('events/user/:userId') - async getUserEvents(@Param('userId') userId: string): Promise { - return this.analyticsService.getEventsByUserId(userId); - } - - @Get('events/type/:eventType') - async getEventsByType(@Param('eventType') eventType: string): Promise { - return this.analyticsService.getEventsByType(eventType); - } - - @Get('events') - async getEventsByDateRange( - @Query('startDate') startDate: string, - @Query('endDate') endDate: string, - ): Promise { - const start = new Date(startDate); - const end = new Date(endDate); - return this.analyticsService.getEventsByDateRange(start, end); - } - - @Get('statistics') - async getStatistics() { - return this.analyticsService.getEventStatistics(); - } - - @Get('events/all') - async getAllEvents(@Query('limit', ParseIntPipe) limit?: number): Promise { - return this.analyticsService.getAllEvents(limit); - } - - @Delete('events/:id') - async deleteEvent(@Param('id') id: string): Promise<{ success: boolean }> { - const success = await this.analyticsService.deleteEvent(id); - return { success }; - } - - @Delete('events/cleanup') - async cleanupOldEvents( - @Query('days', ParseIntPipe) days: number = 30, - ): Promise<{ deletedCount: number }> { - const deletedCount = await this.analyticsService.clearOldEvents(days); - return { deletedCount }; - } -} \ No newline at end of file diff --git a/BackendAcademy/src/analytics/analytics.entity.ts b/BackendAcademy/src/analytics/analytics.entity.ts deleted file mode 100644 index 4708a04298..0000000000 --- a/BackendAcademy/src/analytics/analytics.entity.ts +++ /dev/null @@ -1,21 +0,0 @@ -export class AnalyticsEvent { - id: string; - eventType: string; - userId?: string; - sessionId?: string; - properties?: Record; - timestamp: Date; - ipAddress?: string; - userAgent?: string; - - constructor(data: Partial) { - this.id = data.id || crypto.randomUUID(); - this.eventType = data.eventType || ''; - this.userId = data.userId; - this.sessionId = data.sessionId; - this.properties = data.properties; - this.timestamp = data.timestamp || new Date(); - this.ipAddress = data.ipAddress; - this.userAgent = data.userAgent; - } -} diff --git a/BackendAcademy/src/analytics/analytics.module.ts b/BackendAcademy/src/analytics/analytics.module.ts deleted file mode 100644 index fc8ec228be..0000000000 --- a/BackendAcademy/src/analytics/analytics.module.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Module } from '@nestjs/common'; -import { AnalyticsController } from './analytics.controller'; -import { AnalyticsService } from './analytics.service'; -import { LoggingModule } from '../logging/logging.module'; -import { TransactionManagerService } from '../common/transaction-manager.service'; - -@Module({ - imports: [LoggingModule], - controllers: [AnalyticsController], - providers: [AnalyticsService, TransactionManagerService], - exports: [AnalyticsService], -}) -export class AnalyticsModule {} \ No newline at end of file diff --git a/BackendAcademy/src/analytics/analytics.service.ts b/BackendAcademy/src/analytics/analytics.service.ts deleted file mode 100644 index 21482edaf6..0000000000 --- a/BackendAcademy/src/analytics/analytics.service.ts +++ /dev/null @@ -1,461 +0,0 @@ -import { Injectable, Logger, BadRequestException } from '@nestjs/common'; -import { AnalyticsEvent } from './analytics.entity'; -import { RedisService } from '../redis/redis.service'; -import { v4 as uuidv4 } from 'uuid'; -import { StateReconciliationResult } from '../contracts/interfaces/contracts.interface'; -import { - TransactionManagerService, - TransactionSnapshot, -} from '../common/transaction-manager.service'; -import { CorrelationLoggerService } from '../logging/logger.service'; - -export enum EventType { - USER_REGISTERED = 'user_registered', - USER_LOGIN = 'user_login', - USER_LOGOUT = 'user_logout', - PROFILE_UPDATED = 'profile_updated', - COURSE_ENROLLED = 'course_enrolled', - COURSE_COMPLETED = 'course_completed', - CHALLENGE_STARTED = 'challenge_started', - CHALLENGE_COMPLETED = 'challenge_completed', - CHALLENGE_SUBMITTED = 'challenge_submitted', - BADGE_EARNED = 'badge_earned', - TUTORIAL_STARTED = 'tutorial_started', - TUTORIAL_COMPLETED = 'tutorial_completed', - REWARD_CLAIMED = 'reward_claimed', - LEADERBOARD_VIEWED = 'leaderboard_viewed', - API_KEY_CREATED = 'api_key_created', - API_KEY_REVOKED = 'api_key_revoked', - API_KEY_USED = 'api_key_used', - API_KEY_ANOMALY = 'api_key_anomaly', - SESSION_REVOKED = 'session_revoked', - DEVICE_BOUND = 'device_bound', - PRIVILEGE_CHANGED = 'privilege_changed', - // #394: Reconciliation events - CONTRACT_RECONCILIATION_STARTED = 'contract_reconciliation_started', - CONTRACT_RECONCILIATION_COMPLETED = 'contract_reconciliation_completed', - CONTRACT_REPLAY_STARTED = 'contract_replay_started', - CONTRACT_REPLAY_COMPLETED = 'contract_replay_completed', - // #386: Notification batching events - // #386: Notification delivery analytics - NOTIFICATION_BATCH_FLUSHED = 'notification_batch_flushed', - NOTIFICATION_DELIVERED = 'notification_delivered', -} - -/** - * Summary of reconciliation activity for analytics. - */ -export interface ReconciliationSummary { - totalReconciliations: number; - consistentStateCount: number; - inconsistentStateCount: number; - lastReconciliationAt: Date | null; - totalDiscrepanciesFound: number; -} - -@Injectable() -export class AnalyticsService { - private readonly logger = new Logger(AnalyticsService.name); - private readonly events: AnalyticsEvent[] = []; - - /** Allow-listed event types used by validateEventPayload(). */ - static readonly VALID_EVENT_TYPES: ReadonlySet = new Set( - Object.values(EventType), - ); - - /** #394: History of reconciliation results for analytics */ - private readonly reconciliationHistory: StateReconciliationResult[] = []; - - /** All recognized event types, used to validate incoming payloads. */ - private static readonly VALID_EVENT_TYPES = new Set(Object.values(EventType)); - - constructor( - private readonly redisService?: RedisService, - private readonly transactionManager?: TransactionManagerService, - ) {} - - validateEventPayload(event: Partial): void { - if (!event.eventType) { - throw new BadRequestException('eventType is required'); - } - if (!AnalyticsService.VALID_EVENT_TYPES.has(event.eventType as EventType)) { - throw new BadRequestException( - `Invalid eventType "${event.eventType}". Must be one of: ${Array.from(AnalyticsService.VALID_EVENT_TYPES).join(', ')}`, - ); - } - if (event.properties && typeof event.properties !== 'object') { - throw new BadRequestException('properties must be an object'); - } - if (event.userId && typeof event.userId !== 'string') { - throw new BadRequestException('userId must be a string'); - } - if (event.sessionId && typeof event.sessionId !== 'string') { - throw new BadRequestException('sessionId must be a string'); - } - if (event.ipAddress && typeof event.ipAddress !== 'string') { - throw new BadRequestException('ipAddress must be a string'); - } - if (event.userAgent && typeof event.userAgent !== 'string') { - throw new BadRequestException('userAgent must be a string'); - } - } - - /** - * Track an analytics event atomically. The event is first pushed to the - * in-memory store, then the Redis snapshot is refreshed. If the Redis - * refresh fails, the event is removed from the store so callers never - * observe a partial side-effect. - * - * When a {@link TransactionManagerService} is available the two - * mutations are wrapped in a transactional atomic operation with - * automatic rollback semantics. - */ - async trackEvent(event: Partial): Promise { - if (this.transactionManager) { - return this.trackEventAtomically(event); - } - return this.trackEventLegacy(event); - } - - /** - * Atomic variant of trackEvent (#358). Uses the transaction manager - * to guarantee that the event store and Redis snapshot are always - * in a consistent state. - */ - private async trackEventAtomically(event: Partial): Promise { - const correlationId = event.properties?.correlationId - || CorrelationLoggerService.getCorrelationId(); - - const analyticsEvent = new AnalyticsEvent({ - ...event, - id: event.id || uuidv4(), - timestamp: event.timestamp || new Date(), - properties: { - ...event.properties, - correlationId, - }, - }); - - const txResult = await this.transactionManager!.runAtomic(async (tx) => { - // Step 1: Push event to in-memory store - this.events.push(analyticsEvent); - - await tx.addOperation(async (): Promise => ({ - restore: () => { - const idx = this.events.indexOf(analyticsEvent); - if (idx !== -1) this.events.splice(idx, 1); - }, - data: { eventId: analyticsEvent.id }, - })); - - this.logger?.log(`Event tracked: ${analyticsEvent.eventType}`, 'AnalyticsService'); - - // Step 2: Refresh Redis snapshot (if applicable) - if (this.redisService && analyticsEvent.userId) { - const eventTypes = [analyticsEvent.eventType]; - const interactionData: Record = { - lastInteractionAt: new Date(), - interactionCount: 1, - eventTypes, - correlationId, - }; - - if (analyticsEvent.eventType === EventType.COURSE_ENROLLED) { - interactionData.recentCourses = analyticsEvent.properties?.courseId - ? [analyticsEvent.properties.courseId] - : []; - } - if (analyticsEvent.eventType === EventType.CHALLENGE_COMPLETED) { - interactionData.recentChallenges = analyticsEvent.properties?.challengeId - ? [analyticsEvent.properties.challengeId] - : []; - } - if ( - analyticsEvent.eventType === EventType.CONTRACT_RECONCILIATION_COMPLETED - ) { - interactionData.lastReconciliationAt = new Date(); - } - - await this.redisService.refreshUserSnapshot(analyticsEvent.userId, interactionData); - } - - return analyticsEvent; - }); - - if (!txResult.success) { - this.logger.error( - `Failed to track event ${analyticsEvent.eventType}: ${txResult.error?.message}`, - ); - throw txResult.error; - } - - return txResult.result!; - } - - /** - * Legacy (non-transactional) variant of trackEvent. Used as fallback - * when no TransactionManagerService is injected. - */ - private async trackEventLegacy(event: Partial): Promise { - const correlationId = event.properties?.correlationId - || CorrelationLoggerService.getCorrelationId(); - - const analyticsEvent = new AnalyticsEvent({ - ...event, - id: event.id || uuidv4(), - timestamp: event.timestamp || new Date(), - properties: { - ...event.properties, - correlationId, - }, - }); - this.events.push(analyticsEvent); - - this.logger?.log(`Event tracked: ${analyticsEvent.eventType}`, 'AnalyticsService'); - - if (this.redisService && analyticsEvent.userId) { - const eventTypes = [analyticsEvent.eventType]; - const interactionData: Record = { - lastInteractionAt: new Date(), - interactionCount: 1, - eventTypes, - correlationId, - }; - - if (analyticsEvent.eventType === EventType.COURSE_ENROLLED) { - interactionData.recentCourses = analyticsEvent.properties?.courseId - ? [analyticsEvent.properties.courseId] - : []; - } - if (analyticsEvent.eventType === EventType.CHALLENGE_COMPLETED) { - interactionData.recentChallenges = analyticsEvent.properties?.challengeId - ? [analyticsEvent.properties.challengeId] - : []; - } - // #394: Track reconciliation interactions - if ( - analyticsEvent.eventType === EventType.CONTRACT_RECONCILIATION_COMPLETED - ) { - interactionData.lastReconciliationAt = new Date(); - } - - await this.redisService.refreshUserSnapshot(analyticsEvent.userId, interactionData); - } - - return analyticsEvent; - } - - async getEventsByUserId(userId: string): Promise { - return this.events.filter((event) => event.userId === userId); - } - - async getEventsByType(eventType: string): Promise { - return this.events.filter((event) => event.eventType === eventType); - } - - async getEventsByDateRange(startDate: Date, endDate: Date): Promise { - return this.events.filter( - (event) => event.timestamp >= startDate && event.timestamp <= endDate, - ); - } - - /** - * #354: Removes all analytics events for a given user. - */ - async removeEventsByUserId(userId: string): Promise { - const before = this.events.length; - this.events.splice( - 0, - this.events.length, - ...this.events.filter((e) => e.userId !== userId), - ); - return before - this.events.length; - } - - async getEventStatistics(): Promise<{ - totalEvents: number; - eventsByType: Record; - uniqueUsers: number; - }> { - const eventsByType: Record = {}; - const uniqueUsers = new Set(); - - for (const event of this.events) { - eventsByType[event.eventType] = (eventsByType[event.eventType] || 0) + 1; - if (event.userId) { - uniqueUsers.add(event.userId); - } - } - - return { - totalEvents: this.events.length, - eventsByType, - uniqueUsers: uniqueUsers.size, - }; - } - - async getAllEvents(limit?: number): Promise { - if (limit) { - return this.events.slice(-limit); - } - return this.events; - } - - /** - * Returns events using cursor-based pagination with stable ordering. - */ - async getEventsPaginated(options: { - cursor?: string; - limit: number; - userId?: string; - }): Promise<{ events: AnalyticsEvent[]; nextCursor?: string }> { - let filtered = [...this.events]; - if (options.userId) { - filtered = filtered.filter((e) => e.userId === options.userId); - } - - const sorted = filtered.sort((a, b) => { - const timeDiff = b.timestamp.getTime() - a.timestamp.getTime(); - if (timeDiff !== 0) return timeDiff; - return (b.id ?? '').localeCompare(a.id ?? ''); - }); - - let startIndex = 0; - if (options.cursor) { - const cursorIdx = sorted.findIndex((e) => e.id === options.cursor); - if (cursorIdx !== -1) startIndex = cursorIdx + 1; - } - - const events = sorted.slice(startIndex, startIndex + options.limit); - const nextCursor = - events.length === options.limit - ? events[events.length - 1].id - : undefined; - - return { events, nextCursor }; - } - - async deleteEvent(id: string): Promise { - const index = this.events.findIndex((event) => event.id === id); - if (index === -1) return false; - this.events.splice(index, 1); - return true; - } - - async clearOldEvents(daysToKeep: number = 30): Promise { - const cutoffDate = new Date(); - cutoffDate.setDate(cutoffDate.getDate() - daysToKeep); - - const initialLength = this.events.length; - const filtered = this.events.filter((event) => event.timestamp >= cutoffDate); - this.events.length = 0; - this.events.push(...filtered); - - return initialLength - this.events.length; - } - - // ────────────────────────────────────────────────────────────────── - // #394: Contract reconciliation tracking - // ────────────────────────────────────────────────────────────────── - - /** - * Records a state reconciliation result for analytics tracking. - */ - recordReconciliation(result: StateReconciliationResult): void { - this.reconciliationHistory.push(result); - this.logger.log( - `Reconciliation recorded for ${result.contractId}: consistent=${result.isConsistent}, discrepancies=${result.discrepancies.length}`, - ); - - // Limit history size - if (this.reconciliationHistory.length > 1000) { - this.reconciliationHistory.splice(0, this.reconciliationHistory.length - 1000); - } - } - - /** - * Returns reconciliation history, optionally filtered by contract. - */ - getReconciliationHistory(contractId?: string): StateReconciliationResult[] { - const history = [...this.reconciliationHistory]; - history.sort( - (a, b) => b.reconciledAt.getTime() - a.reconciledAt.getTime(), - ); - return contractId - ? history.filter((r) => r.contractId === contractId) - : history; - } - - /** - * Returns a summary of all reconciliation activity. - */ - getReconciliationSummary(): ReconciliationSummary { - let consistent = 0; - let inconsistent = 0; - let totalDiscrepancies = 0; - let lastAt: Date | null = null; - - for (const result of this.reconciliationHistory) { - if (result.isConsistent) { - consistent++; - } else { - inconsistent++; - } - totalDiscrepancies += result.discrepancies.length; - - if (!lastAt || result.reconciledAt > lastAt) { - lastAt = result.reconciledAt; - } - } - - return { - totalReconciliations: this.reconciliationHistory.length, - consistentStateCount: consistent, - inconsistentStateCount: inconsistent, - lastReconciliationAt: lastAt, - totalDiscrepanciesFound: totalDiscrepancies, - }; - } - - // ── Notification batching analytics (#386) ──────────────── - - /** - * Tracks a notification batch flush event. - */ - async trackBatchFlush( - batchId: string, - totalCount: number, - successCount: number, - failureCount: number, - ): Promise { - await this.trackEvent({ - eventType: EventType.NOTIFICATION_BATCH_FLUSHED, - properties: { - batchId, - totalCount, - successCount, - failureCount, - }, - }); - } - - /** - * Tracks a single notification delivery event. - */ - async trackNotificationDelivery( - notificationId: string, - userId: string, - providerId: string, - success: boolean, - ): Promise { - await this.trackEvent({ - eventType: EventType.NOTIFICATION_DELIVERED, - userId, - properties: { - notificationId, - providerId, - success, - }, - }); - } -} diff --git a/BackendAcademy/src/analytics/dto/create-event.dto.ts b/BackendAcademy/src/analytics/dto/create-event.dto.ts deleted file mode 100644 index aa60b083f5..0000000000 --- a/BackendAcademy/src/analytics/dto/create-event.dto.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { IsString, IsOptional, IsObject, IsDateString } from 'class-validator'; - -export class CreateEventDto { - @IsString() - eventType: string; - - @IsString() - @IsOptional() - userId?: string; - - @IsString() - @IsOptional() - sessionId?: string; - - @IsObject() - @IsOptional() - properties?: Record; - - @IsString() - @IsOptional() - ipAddress?: string; - - @IsString() - @IsOptional() - userAgent?: string; -} diff --git a/BackendAcademy/src/analytics/dto/index.ts b/BackendAcademy/src/analytics/dto/index.ts deleted file mode 100644 index 94f8ee3547..0000000000 --- a/BackendAcademy/src/analytics/dto/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './create-event.dto'; diff --git a/BackendAcademy/src/api-info.controller.ts b/BackendAcademy/src/api-info.controller.ts deleted file mode 100644 index e0825bad7b..0000000000 --- a/BackendAcademy/src/api-info.controller.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Controller, Get } from '@nestjs/common'; - -import { LocalizationService } from './i18n/localization.service'; - -@Controller('api') -export class ApiInfoController { - constructor(private readonly l10n: LocalizationService) {} - - Get() - getApiInfo() { - return { - success: true, - data: { - name: this.l10n.t('api.name'), - version: process.env.npm_package_version || '1.0.0', - status: this.l10n.t('api.status'), - locale: this.l10n.getLocale(), - availableLocales: this.l10n.getAvailableLocales(), - docs: '/api/docs', - features: { - ai: { - recommendations: true, - explainability: true, - modelVersion: 'rustacademy-recommender-v2', - }, - payments: { - coupons: true, - redemptionHistory: true, - }, - }, - }, - }; - } -} diff --git a/BackendAcademy/src/app.controller.spec.ts b/BackendAcademy/src/app.controller.spec.ts new file mode 100644 index 0000000000..4f5dfe55e5 --- /dev/null +++ b/BackendAcademy/src/app.controller.spec.ts @@ -0,0 +1,22 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AppController } from './app.controller'; +import { AppService } from './app.service'; + +describe('AppController', () => { + let appController: AppController; + + beforeEach(async () => { + const app: TestingModule = await Test.createTestingModule({ + controllers: [AppController], + providers: [AppService], + }).compile(); + + appController = app.get(AppController); + }); + + describe('getHello', () => { + it('should return the service greeting', () => { + expect(appController.getHello()).toBe('RustAcademy BackendAcademy API'); + }); + }); +}); diff --git a/BackendAcademy/src/app.module.spec.ts b/BackendAcademy/src/app.module.spec.ts deleted file mode 100644 index 2b57de7c62..0000000000 --- a/BackendAcademy/src/app.module.spec.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { MODULE_METADATA } from '@nestjs/common/constants'; -import { AppController } from './app.controller'; -import { AppModule } from './app.module'; - -describe('AppModule bootstrap registration', () => { - it('registers every production feature module at the root', () => { - const imports = Reflect.getMetadata(MODULE_METADATA.IMPORTS, AppModule) as Array< - | { module?: { name: string } } - | { name: string } - >; - const importedModuleNames = imports.map( - importedModule => importedModule.module?.name ?? importedModule.name, - ); - - expect(importedModuleNames).toEqual( - expect.arrayContaining([ - 'AdminModule', - 'AiModule', - 'AnalyticsModule', - 'AssetsModule', - 'AuthModule', - 'BadgesModule', - 'ChallengesModule', - 'ChatModule', - 'ContractsModule', - 'CourseModule', - 'DatabaseModule', - 'DlqModule', - 'HealthModule', - 'I18nModule', - 'JobsModule', - 'LeaderboardModule', - 'LessonModule', - 'LoggingModule', - 'MonitoringModule', - 'NotificationsModule', - 'OnboardingModule', - 'PaymentsModule', - 'PathfindingModule', - 'ProgressModule', - 'RedisModule', - 'ReportsModule', - 'RewardsModule', - 'SearchModule', - 'SecurityModule', - 'SessionsModule', - 'SocialModule', - 'SubmissionModule', - 'TaskModule', - 'TutorProfileModule', - 'UserProfileModule', - 'UsersModule', - 'WalletModule', - ]), - ); - }); - - it('keeps root-level controllers intentional', () => { - expect(Reflect.getMetadata(MODULE_METADATA.CONTROLLERS, AppModule)).toEqual([ - AppController, - ]); - }); -}); \ No newline at end of file diff --git a/BackendAcademy/src/app.module.ts b/BackendAcademy/src/app.module.ts index ddcd3d4ae5..8eb16da368 100644 --- a/BackendAcademy/src/app.module.ts +++ b/BackendAcademy/src/app.module.ts @@ -1,109 +1,23 @@ -import { Module, MiddlewareConsumer, NestModule, ValidationPipe } from '@nestjs/common'; -import { APP_GUARD, APP_FILTER, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'; -import { ThrottleGuard, ThrottleModule } from '@nestjs/throttler'; +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { APP_GUARD } from '@nestjs/core'; +import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; import { AppController } from './app.controller'; import { AppService } from './app.service'; -import { AdminModule } from './admin/admin.module'; -import { AuthModule } from './auth/auth.module'; -import { BadgesModule } from './badges/badges.module'; -import { ChallengesModule } from './challenges/challenges.module'; -import { ChatModule } from './chat/chat.module'; -import { RewardsModule } from './rewards/rewards.module'; -import { SecurityModule } from './security/security.module'; -import { SubmissionModule } from './submissions/submission.module'; -import { UsersModule } from './users/users.module'; -import { TutorProfileModule } from './users/tutor-profile.module'; -import { ContractsModule } from './contracts/contracts.module'; -import { UserProfileModule } from './users/user-profile.module'; -import { AppConfigModule } from './config/config.module'; -import { AssetsModule } from './assets/assets.module'; -import { DatabaseModule } from './database/database.module'; -import { DlqModule } from './dead-letter-queue/dlq.module'; -import { PathfindingModule } from './pathfinding/pathfinding.module'; -import { MonitoringModule } from './monitoring/monitoring.module'; -import { SearchModule } from './search/search.module'; -import { PaymentsModule } from './payments/payments.module'; -import { I18nModule } from './i18n/i18n.module'; -import { NotificationsModule } from './notifications/notifications.module'; -import { HealthModule } from './health/health.module'; -import { CorrelationIDMiddleware } from './common/correlation-id.middleware'; -import { AllExceptionsFilter } from './common/filters/all-exceptions.filter'; -import { TransformInterceptor } from './common/interceptors/transform.interceptor'; -import { AiModule } from './ai/ai.module'; -import { JobsModule } from './jobs/jobs.module'; -import { LeaderboardModule } from './leaderboard/leaderboard.module'; -import { AnalyticsModule } from './analytics/analytics.module'; -import { WalletModule } from './wallet/wallet.module'; -import { SocialModule } from './social/social.module'; -import { OnboardingModule } from './onboarding/onboarding.module'; -import { LessonModule } from './lessons/lesson.module'; -import { TaskModule } from './tasks/task.module'; -import { CourseModule } from './courses/course.module'; -import { LoggingModule } from './logging/logging.module'; -import { ProgressModule } from './courses/progress/progress.module'; -import { RedisModule } from './redis/redis.module'; -import { ReportsModule } from './reports/reports.module'; -import { SessionsModule } from './sessions/sessions.module'; -import { AuditModule } from './audit/audit.module'; -import { HintsModule } from './hints/hint.module'; +import { HealthController } from './health/health.controller'; @Module({ imports: [ - AppConfigModule, - ThrottlerModule.forRoot([{ ttl: 60000, limit: 10 }]), - DatabaseModule, - RedisModule, - AuthModule, - ContractsModule, - AdminModule, - BadgesModule, - ChatModule, - UsersModule, - ContractsModule, - RedisModule, - AuditModule, - UserProfileModule, - TutorProfileModule, - SubmissionModule, - RewardsModule, - SecurityModule, - ChallengesModule, - AiModule, - LeaderboardModule, - AnalyticsModule, - WalletModule, - SocialModule, - OnboardingModule, - LessonModule, - TaskModule, - CourseModule, - ProgressModule, - SessionsModule, - ReportsModule, - JobsModule, - DlqModule, - AssetsModule, - LoggingModule, - PathfindingModule, - MonitoringModule, - SearchModule, - PaymentsModule, - I18nModule, - NotificationsModule, - HealthModule, - HintsModule, - ], - controllers: [AppController], - providers: [ - AppService, - { provide: APP_GUARD, useClass: ThrottleGuard }, - { provide: APP_FILTER, useClass: AllExceptionsFilter }, - { provide: APP_INTERCEPTOR, useClass: TransformInterceptor }, - { provide: APP_PIPE, useValue: new ValidationPipe({ transform: true }) }, + ConfigModule.forRoot({ isGlobal: true }), + ThrottlerModule.forRoot([ + { + name: 'default', + ttl: Number(process.env.THROTTLE_TTL_MS ?? 60000), + limit: Number(process.env.THROTTLE_LIMIT ?? 100), + }, + ]), ], + controllers: [AppController, HealthController], + providers: [AppService, { provide: APP_GUARD, useClass: ThrottlerGuard }], }) -export class AppModule implements NestModule { - configure(consumer: MiddlewareConsumer) { - consumer.apply(CorrelationIDMiddleware).forRoutes('*'); - } -} \ No newline at end of file +export class AppModule {} diff --git a/BackendAcademy/src/app.service.ts b/BackendAcademy/src/app.service.ts index dc94d7e384..ca511ac91e 100644 --- a/BackendAcademy/src/app.service.ts +++ b/BackendAcademy/src/app.service.ts @@ -3,6 +3,6 @@ import { Injectable } from '@nestjs/common'; @Injectable() export class AppService { getHello(): string { - return 'Hello from RustAcademy Backend!'; + return 'RustAcademy BackendAcademy API'; } } diff --git a/BackendAcademy/src/assets/assets.controller.spec.ts b/BackendAcademy/src/assets/assets.controller.spec.ts deleted file mode 100644 index 1ab1847d19..0000000000 --- a/BackendAcademy/src/assets/assets.controller.spec.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { AssetsController } from './assets.controller'; -import { AssetsService } from './assets.service'; -import { NotFoundException } from '@nestjs/common'; -import type { Asset, AssetListResponse } from './interfaces/asset.interface'; -import { SecurityService } from '../security/security.service'; - -describe('AssetsController', () => { - let controller: AssetsController; - let service: jest.Mocked; - - const sampleAsset: Asset = { - id: '11111111-1111-4111-8111-111111111111', - filename: 'asset-11111111-1111-4111-8111-111111111111.txt', - originalName: 'sample.txt', - mimeType: 'text/plain', - size: 12, - uploadedAt: '2025-01-01T00:00:00.000Z', - url: '/api/v1/assets/11111111-1111-4111-8111-111111111111/download', - name: 'Sample', - contentHash: 'abc', - }; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [AssetsController], - providers: [ - { - provide: AssetsService, - useValue: { - list: jest.fn(), - findById: jest.fn(), - openReadStream: jest.fn(), - registerBuffer: jest.fn(), - remove: jest.fn(), - getMaxSizeBytes: jest.fn().mockReturnValue(10 * 1024 * 1024), - getAllowedMimeTypes: jest - .fn() - .mockReturnValue([{ mime: 'image/png', prefix: false }, { mime: 'text/', prefix: true }]), - }, - }, - { provide: SecurityService, useValue: {} }, - ], - }).compile(); - - controller = module.get(AssetsController); - service = module.get(AssetsService) as jest.Mocked; - }); - - it('lists assets with newest sort by default', () => { - const response: AssetListResponse = { total: 1, assets: [sampleAsset] }; - service.list.mockReturnValue(response); - - expect(controller.list(undefined)).toEqual(response); - expect(service.list).toHaveBeenCalledWith('newest'); - }); - - it('passes through explicit sort orders', () => { - service.list.mockReturnValue({ total: 0, assets: [] }); - - controller.list('oldest'); - expect(service.list).toHaveBeenLastCalledWith('oldest'); - - controller.list('name'); - expect(service.list).toHaveBeenLastCalledWith('name'); - - controller.list('bogus'); - expect(service.list).toHaveBeenLastCalledWith('newest'); - }); - - it('looks up an asset by id', () => { - service.findById.mockReturnValue(sampleAsset); - expect(controller.findOne(sampleAsset.id)).toEqual(sampleAsset); - }); - - it('returns an open stream when downloading an asset', async () => { - service.findById.mockReturnValue(sampleAsset); - - const stream = { on: jest.fn(), pipe: jest.fn() } as unknown as NodeJS.ReadableStream & { - on: jest.Mock; - pipe: jest.Mock; - }; - service.openReadStream.mockResolvedValue(stream as never); - - const result = await controller.download(sampleAsset.id); - - expect(result).toBeDefined(); - expect(service.openReadStream).toHaveBeenCalledWith(sampleAsset.id); - }); - - it('propagates NotFoundException on download for missing assets', async () => { - service.findById.mockImplementation(() => { - throw new NotFoundException(`Asset 'missing' not found`); - }); - - await expect( - controller.download('22222222-2222-4222-8222-222222222222'), - ).rejects.toThrow(NotFoundException); - }); - - it('registers an upload through the service', async () => { - const file = { - buffer: Buffer.from('hello'), - originalname: 'hello.txt', - mimetype: 'text/plain', - size: 5, - } as unknown as Express.Multer.File; - const dto = { name: 'Hello', description: 'A greeting' }; - - service.registerBuffer.mockResolvedValue(sampleAsset); - - const result = await controller.upload(file, dto); - - expect(result).toEqual(sampleAsset); - expect(service.registerBuffer).toHaveBeenCalledWith({ - buffer: file.buffer, - originalName: file.originalname, - mimeType: file.mimetype, - size: file.size, - name: dto.name, - description: dto.description, - }); - }); - - it('removes an asset via the service', async () => { - service.remove.mockResolvedValue(undefined); - await controller.remove(sampleAsset.id); - expect(service.remove).toHaveBeenCalledWith(sampleAsset.id); - }); -}); diff --git a/BackendAcademy/src/assets/assets.controller.ts b/BackendAcademy/src/assets/assets.controller.ts deleted file mode 100644 index 46471a1210..0000000000 --- a/BackendAcademy/src/assets/assets.controller.ts +++ /dev/null @@ -1,251 +0,0 @@ -import { - Body, - Controller, - Delete, - Get, - HttpCode, - HttpStatus, - Param, - ParseUUIDPipe, - Post, - Query, - StreamableFile, - UploadedFile, - UseInterceptors, - UseFilters, - Catch, - ArgumentsHost, - ExceptionFilter, - BadRequestException, -} from '@nestjs/common'; -import { FileInterceptor } from '@nestjs/platform-express'; -import { memoryStorage, MulterError } from 'multer'; -import type { Response } from 'express'; -import type { NestInterceptor } from '@nestjs/common'; -import type { Options as MulterOptions } from 'multer'; -import { - ApiBody, - ApiConsumes, - ApiOperation, - ApiParam, - ApiProduces, - ApiResponse, - ApiTags, -} from '@nestjs/swagger'; - -import { AssetsService, ALLOWED_MIME_TYPES } from './assets.service'; -import { UploadAssetDto } from './dto/upload-asset.dto'; -import type { Asset, AssetListResponse, AssetSortOrder } from './interfaces/asset.interface'; - -/** Maximum per-file upload size (mirrors `ASSETS_MAX_SIZE_MB`, default 10 MB). */ -const MAX_UPLOAD_BYTES = - (() => { - const mb = Number(process.env.ASSETS_MAX_SIZE_MB ?? 10); - return Number.isFinite(mb) && mb > 0 ? Math.floor(mb * 1024 * 1024) : 10 * 1024 * 1024; - })(); - -/** - * Maps multer upload failures onto clean HTTP responses: - * - oversized uploads → 413 (Payload Too Large) - * - wrong field name / unexpected file → 400 (Bad Request) - * This keeps the controller body free of transport-specific error handling. - */ -@Catch(MulterError) -class MulterExceptionFilter implements ExceptionFilter { - catch(exception: MulterError, host: ArgumentsHost) { - const ctx = host.switchToHttp(); - const res = ctx.getResponse(); - if (exception.code === 'LIMIT_FILE_SIZE') { - res.status(HttpStatus.PAYLOAD_TOO_LARGE).json({ - statusCode: HttpStatus.PAYLOAD_TOO_LARGE, - message: 'Uploaded asset exceeds the maximum allowed size', - error: 'Payload Too Large', - }); - return; - } - res.status(HttpStatus.BAD_REQUEST).json({ - statusCode: HttpStatus.BAD_REQUEST, - message: exception.message, - error: 'Bad Request', - }); - } -} - -/** - * Builds multer options with strict bounds, reading live limits from the - * injected service. Declared as a function so the interceptor can be bound to - * a concrete controller instance in the constructor. - */ -function buildUploadOptions(assetsService: AssetsService): MulterOptions { - return { - storage: memoryStorage(), - limits: { - fileSize: assetsService.getMaxSizeBytes(), - files: 1, - fieldSize: 1024 * 1024, - fields: 10, - parts: 12, - }, - fileFilter: (_req, file, cb) => { - const declared = (file.mimetype || '').toLowerCase(); - const isAllowed = ALLOWED_MIME_TYPES.some(({ mime, prefix }) => - prefix ? declared.startsWith(mime) : declared === mime, - ); - - if ( - !isAllowed || - /^(?:application\/(?:x-|octet-stream)|text\/html|image\/svg\+xml)/.test( - declared, - ) - ) { - cb( - new BadRequestException( - `Asset MIME type '${file.mimetype}' is not allowed`, - ), - false, - ); - return; - } - - const lower = (file.originalname || '').toLowerCase(); - if ( - /\.(?:exe|dll|sh|bat|cmd|msi|php|phtml|js|cgi|pl|py|rb|jar|com|scr|vbs|wsf)$/.test( - lower, - ) - ) { - cb( - new BadRequestException('Asset filename uses a disallowed extension'), - false, - ); - return; - } - - cb(null, true); - }, - }; -} - -@ApiTags('assets') -@Controller('assets') -@UseFilters(MulterExceptionFilter) -export class AssetsController { - private readonly uploadInterceptor: NestInterceptor; - - constructor(private readonly assetsService: AssetsService) { - this.uploadInterceptor = new FileInterceptor('file', buildUploadOptions(assetsService)); - } - - /** - * `GET /assets` — list all stored assets, optionally sorted. - */ - @Get() - @ApiOperation({ summary: 'List stored assets' }) - @ApiQuery({ - name: 'sort', - required: false, - enum: ['newest', 'oldest', 'name'], - description: 'Sort order; defaults to `newest`.', - }) - @ApiResponse({ status: 200, description: 'List of asset metadata.' }) - list(@Query('sort') sort?: string): AssetListResponse { - const normalized: AssetSortOrder = - sort === 'oldest' || sort === 'name' ? sort : 'newest'; - return this.assetsService.list(normalized); - } - - /** - * `GET /assets/:id` — fetch metadata for a single asset. - */ - @Get(':id') - @ApiOperation({ summary: 'Get asset metadata' }) - @ApiParam({ name: 'id', format: 'uuid' }) - @ApiResponse({ status: 200, description: 'Asset metadata found.' }) - @ApiResponse({ status: 404, description: 'Asset not found.' }) - findOne(@Param('id', new ParseUUIDPipe()) id: string): Asset { - return this.assetsService.findById(id); - } - - /** - * `GET /assets/:id/download` — stream the asset content back to the - * client. `Content-Type`, length and disposition are attached directly - * to the `StreamableFile` so we avoid mixing `@Res` directives with the - * controller return value. - */ - @Get(':id/download') - @ApiOperation({ summary: 'Download asset content' }) - @ApiProduces('application/octet-stream', 'image/*', 'video/*', 'audio/*') - @ApiParam({ name: 'id', format: 'uuid' }) - @ApiResponse({ status: 200, description: 'Asset content stream.' }) - @ApiResponse({ status: 404, description: 'Asset not found.' }) - async download( - @Param('id', new ParseUUIDPipe()) id: string, - ): Promise { - const asset = this.assetsService.findById(id); - const stream = await this.assetsService.openReadStream(id); - - return new StreamableFile(stream, { - type: asset.mimeType, - length: asset.size, - disposition: `attachment; filename="${asset.originalName.replace(/["\\]/g, '')}"`, - }); - } - - /** - * `POST /assets` — upload a new asset via `multipart/form-data`. - * - * The accompanying text fields are validated against `UploadAssetDto` - * by the global `ValidationPipe`. multer is configured with strict limits - * and a `fileFilter` so malformed or disallowed uploads are rejected before - * the bytes are buffered into memory, and the service performs content- - * based inspection (magic bytes, quotas, dangerous-content screening) - * before the file is persisted. - */ - @Post() - @HttpCode(HttpStatus.CREATED) - @UseInterceptors(this.uploadInterceptor) - @ApiConsumes('multipart/form-data') - @ApiBody({ - schema: { - type: 'object', - properties: { - file: { type: 'string', format: 'binary' }, - name: { type: 'string', nullable: true }, - description: { type: 'string', nullable: true }, - }, - required: ['file'], - }, - }) - @ApiOperation({ summary: 'Upload an asset' }) - @ApiResponse({ status: 201, description: 'Asset successfully stored.' }) - @ApiResponse({ status: 400, description: 'Invalid asset payload.' }) - @ApiResponse({ status: 413, description: 'Asset exceeds maximum size.' }) - async upload( - @UploadedFile() file: Express.Multer.File, - @Body() dto: UploadAssetDto, - ): Promise { - if (!file) { - throw new BadRequestException('No file provided under field "file"'); - } - return this.assetsService.registerBuffer({ - buffer: file.buffer, - originalName: file.originalname, - mimeType: file.mimetype || 'application/octet-stream', - size: file.size, - name: dto.name, - description: dto.description, - }); - } - - /** - * `DELETE /assets/:id` — remove an asset and its underlying file. - */ - @Delete(':id') - @HttpCode(HttpStatus.NO_CONTENT) - @ApiOperation({ summary: 'Delete an asset' }) - @ApiParam({ name: 'id', format: 'uuid' }) - @ApiResponse({ status: 204, description: 'Asset removed.' }) - @ApiResponse({ status: 404, description: 'Asset not found.' }) - async remove(@Param('id', new ParseUUIDPipe()) id: string): Promise { - await this.assetsService.remove(id); - } -} diff --git a/BackendAcademy/src/assets/assets.module.ts b/BackendAcademy/src/assets/assets.module.ts deleted file mode 100644 index 62efe61084..0000000000 --- a/BackendAcademy/src/assets/assets.module.ts +++ /dev/null @@ -1,56 +0,0 @@ -import {'Module' } from '@nestjs/common'; -import {'AssetsController' } from './assets.controller'; -import {'AssetsService' } from './assets.service'; -import {'SecurityModule' } from '../security/security.module'; - -/** - * Module exposing asset upload, metadata, download, and delete endpoints - * to the rest of the BackendAcademy application. - */ -@Module({ - imports: [SecurityModule], - controllers: [AssetsController], - providers: [AssetsService], - exports: [AssetsService], -}) -export class AssetsModule { - constructor() { - AssetsModule.validateAssetEnvironment(); - } - - /** - * Validates and coerces asset-related environment variables at startup. - * Throws an Error if any defined value is malformed or out of range. - */ - private static validateAssetEnvironment(): void { - const validations: Array<{ name: string; type: 'number' | 'boolean'; min?: number; max?: number }> = [ - { name: 'ASSET_MAX_SIZE', type: 'number', min: 1 }, - { name: 'ASSET_MAX_COUNT', type: 'number', min: 1 }, - { name: 'ASSET_TTL', type: 'number', min: 0 }, - { name: 'ASSET_PUBLIC_ENDPOINT', type: 'boolean' }, - ]; - - for (const config of validations) { - const raw = process.env[config.name]; - if (raw === undefined) continue; // Optional variable; skip if not set. - - if (config.type === 'number') { - const num = Number(raw); - if (!Number.isFinite(num)) { - throw new Error(`Environment variable ${config.name} must be a valid number (received "${raw}")`); - } - if (config.min !== undefined && num < config.min) { - throw new Error(`Environment variable ${config.name} must be >= ${config.min} (received ${num})`); - } - if (config.max !== undefined && num > config.max) { - throw new Error(`Environment variable ${config.name} must be <= ${config.max} (received ${num})`); - } - } else if (config.type === 'boolean') { - const normalized = raw.trim().toLowerCase(); - if (!['true', 'false', '1', '0'].includes(normalized)) { - throw new Error(`Environment variable ${config.name} must be a boolean (received "${raw}")`); - } - } - } - } -} \ No newline at end of file diff --git a/BackendAcademy/src/assets/assets.service.spec.ts b/BackendAcademy/src/assets/assets.service.spec.ts deleted file mode 100644 index 0f363dbdf0..0000000000 --- a/BackendAcademy/src/assets/assets.service.spec.ts +++ /dev/null @@ -1,346 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { ConfigService } from '@nestjs/config'; -import { BadRequestException, NotFoundException } from '@nestjs/common'; -import { promises as fs } from 'node:fs'; -import * as os from 'node:os'; -import * as path from 'node:path'; - -import { AssetsService, ALLOWED_MIME_TYPES } from './assets.service'; -import { SecurityService } from '../security/security.service'; -import type { Asset } from './interfaces/asset.interface'; - -function pngBuffer(): Buffer { - // Minimal valid 1x1 PNG magic header (content inspection will accept it). - return Buffer.from( - '89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c6360000002000001e221bc330000000049454e44ae426082', - 'hex', - ); -} - -function textBuffer(content: string): Buffer { - return Buffer.from(content, 'utf-8'); -} - -describe('AssetsService', () => { - let service: AssetsService; - let tmpDir: string; - let originalUploadDir: string | undefined; - let originalBaseUrl: string | undefined; - let originalMaxMb: string | undefined; - let originalTotalMb: string | undefined; - let originalMaxCount: string | undefined; - - beforeEach(async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'assets-spec-')); - originalUploadDir = process.env.ASSETS_UPLOAD_DIR; - originalBaseUrl = process.env.ASSETS_BASE_URL; - originalMaxMb = process.env.ASSETS_MAX_SIZE_MB; - originalTotalMb = process.env.ASSETS_MAX_TOTAL_MB; - originalMaxCount = process.env.ASSETS_MAX_COUNT; - process.env.ASSETS_UPLOAD_DIR = tmpDir; - process.env.ASSETS_MAX_SIZE_MB = '5'; - process.env.ASSETS_MAX_TOTAL_MB = '1'; - process.env.ASSETS_MAX_COUNT = '3'; - process.env.ASSETS_BASE_URL = '/api/v1/assets'; - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - AssetsService, - { - provide: ConfigService, - useValue: { - get: (key: string) => { - if (key === 'ASSETS_UPLOAD_DIR') return tmpDir; - if (key === 'ASSETS_MAX_SIZE_MB') return '5'; - if (key === 'ASSETS_MAX_TOTAL_MB') return '1'; - if (key === 'ASSETS_MAX_COUNT') return '3'; - if (key === 'ASSETS_BASE_URL') return '/api/v1/assets'; - return undefined; - }, - }, - }, - { - provide: SecurityService, - useValue: { computeContentHash: (b: Buffer) => 'hash-' + b.length }, - }, - ], - }).compile(); - - service = module.get(AssetsService); - // Allow the eager mkdir inside the service constructor to settle. - await new Promise((resolve) => setImmediate(resolve)); - }); - - afterEach(async () => { - process.env.ASSETS_UPLOAD_DIR = originalUploadDir; - process.env.ASSETS_BASE_URL = originalBaseUrl; - process.env.ASSETS_MAX_SIZE_MB = originalMaxMb; - process.env.ASSETS_MAX_TOTAL_MB = originalTotalMb; - process.env.ASSETS_MAX_COUNT = originalMaxCount; - await fs.rm(tmpDir, { recursive: true, force: true }); - }); - - it('persists a buffered upload and exposes metadata', async () => { - const asset = await service.registerBuffer({ - buffer: Buffer.from('hello world'), - originalName: 'greeting.txt', - mimeType: 'text/plain', - size: 11, - name: 'Greeting', - description: 'A small text greeting', - }); - - expect(asset.id).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, - ); - expect(asset.size).toBe(11); - expect(asset.originalName).toBe('greeting.txt'); - expect(asset.url).toBe(`/api/v1/assets/${asset.id}/download`); - expect(asset.name).toBe('Greeting'); - expect(asset.description).toBe('A small text greeting'); - - const onDisk = path.join(tmpDir, asset.filename); - const written = await fs.readFile(onDisk, 'utf-8'); - expect(written).toBe('hello world'); - }); - - it('rejects uploads with disallowed MIME types', async () => { - await expect( - service.registerBuffer({ - buffer: Buffer.from('???'), - originalName: 'script.bin', - mimeType: 'application/x-msdos-program', - size: 3, - }), - ).rejects.toThrow(BadRequestException); - }); - - it('rejects zero-byte and over-limit uploads', async () => { - await expect( - service.registerBuffer({ - buffer: Buffer.alloc(0), - originalName: 'empty.bin', - mimeType: 'image/png', - size: 0, - }), - ).rejects.toThrow(BadRequestException); - - await expect( - service.registerBuffer({ - buffer: Buffer.alloc(6 * 1024 * 1024), - originalName: 'big.png', - mimeType: 'image/png', - size: 6 * 1024 * 1024, - }), - ).rejects.toThrow(BadRequestException); - }); - - it('lists assets newest first by default', async () => { - const first = await service.registerBuffer({ - buffer: Buffer.from('1'), - originalName: 'a.txt', - mimeType: 'text/plain', - size: 1, - }); - // Force the second upload to have a strictly newer timestamp. - await new Promise((resolve) => setTimeout(resolve, 5)); - const second = await service.registerBuffer({ - buffer: Buffer.from('2'), - originalName: 'b.txt', - mimeType: 'text/plain', - size: 1, - }); - - const newest = service.list(); - expect(newest.total).toBe(2); - expect(newest.assets[0].id).toBe(second.id); - expect(newest.assets[1].id).toBe(first.id); - - const oldest = service.list('oldest'); - expect(oldest.assets[0].id).toBe(first.id); - - const byName = service.list('name'); - expect(byName.assets[0].originalName).toBe('a.txt'); - expect(byName.assets[1].originalName).toBe('b.txt'); - }); - - it('throws NotFoundException for lookups of unknown assets', () => { - expect(() => service.findById('11111111-1111-4111-8111-111111111111')).toThrow( - NotFoundException, - ); - }); - - it('deletes an asset and removes its file from disk', async () => { - const asset = await service.registerBuffer({ - buffer: Buffer.from('bye'), - originalName: 'bye.txt', - mimeType: 'text/plain', - size: 3, - }); - - const onDisk = path.join(tmpDir, asset.filename); - await expect(fs.access(onDisk)).resolves.toBeUndefined(); - - await service.remove(asset.id); - await expect(fs.access(onDisk)).rejects.toThrow(); - - expect(() => service.findById(asset.id)).toThrow(NotFoundException); - }); - - it('produces a sanitized filename that contains the asset id and a safe extension', async () => { - const asset = await service.registerBuffer({ - buffer: textBuffer('x'), - originalName: '../etc/passwd', - mimeType: 'text/plain', - size: 1, - }); - expect(asset.filename).not.toContain('..'); - expect(asset.filename).not.toContain('/'); - expect(asset.filename).toContain(asset.id); - expect(asset.filename.endsWith('.txt')).toBe(true); - }); - - it('uses the default size limit when ASSETS_MAX_SIZE_MB is invalid', async () => { - const altTmp = await fs.mkdtemp(path.join(os.tmpdir(), 'assets-spec-alt-')); - const module: TestingModule = await Test.createTestingModule({ - providers: [ - AssetsService, - { - provide: ConfigService, - useValue: { - get: (key: string) => { - if (key === 'ASSETS_UPLOAD_DIR') return altTmp; - if (key === 'ASSETS_MAX_SIZE_MB') return 'not-a-number'; - if (key === 'ASSETS_MAX_TOTAL_MB') return undefined; - if (key === 'ASSETS_MAX_COUNT') return undefined; - if (key === 'ASSETS_BASE_URL') return '/api/v1/assets'; - return undefined; - }, - }, - }, - { - provide: SecurityService, - useValue: { computeContentHash: (b: Buffer) => 'hash-' + b.length }, - }, - ], - }).compile(); - - const altService = module.get(AssetsService); - expect(altService.getMaxSizeBytes()).toBe(10 * 1024 * 1024); - await fs.rm(altTmp, { recursive: true, force: true }); - }); - - describe('content inspection', () => { - it('stores image files based on their real signature, not the declared type', async () => { - const asset = await service.registerBuffer({ - buffer: pngBuffer(), - originalName: 'photo.png', - mimeType: 'image/png', - size: pngBuffer().length, - }); - - expect(asset.mimeType).toBe('image/png'); - expect(asset.filename.endsWith('.png')).toBe(true); - }); - - it('rejects a spoofed image whose bytes are actually an executable', async () => { - const exe = Buffer.concat([Buffer.from([0x4d, 0x5a, 0x90, 0x00]), Buffer.alloc(64, 0)]); - await expect( - service.registerBuffer({ - buffer: exe, - originalName: 'trojan.png', - mimeType: 'image/png', - size: exe.length, - }), - ).rejects.toThrow(BadRequestException); - }); - - it('rejects HTML / SVG disguised as text', async () => { - await expect( - service.registerBuffer({ - buffer: textBuffer(''), - originalName: 'note.txt', - mimeType: 'text/plain', - size: 25, - }), - ).rejects.toThrow(BadRequestException); - }); - - it('rejects non-UTF-8 binary content declared as text', async () => { - await expect( - service.registerBuffer({ - buffer: Buffer.from([0x00, 0x01, 0x02, 0xff, 0xfe]), - originalName: 'blob.txt', - mimeType: 'text/plain', - size: 5, - }), - ).rejects.toThrow(BadRequestException); - }); - }); - - describe('quotas', () => { - it('enforces the aggregate byte quota', async () => { - // 1 MB total budget -> a 600 KB file fits, a second one is rejected. - const big = Buffer.alloc(600 * 1024, 0x41); - await service.registerBuffer({ - buffer: big, - originalName: 'a.txt', - mimeType: 'text/plain', - size: big.length, - }); - - await expect( - service.registerBuffer({ - buffer: big, - originalName: 'b.txt', - mimeType: 'text/plain', - size: big.length, - }), - ).rejects.toThrow(/quota/); - }); - - it('enforces the maximum asset count', async () => { - for (let i = 0; i < 3; i++) { - await service.registerBuffer({ - buffer: textBuffer(`item-${i}`), - originalName: `item-${i}.txt`, - mimeType: 'text/plain', - size: `item-${i}`.length, - }); - } - - await expect( - service.registerBuffer({ - buffer: textBuffer('overflow'), - originalName: 'overflow.txt', - mimeType: 'text/plain', - size: 8, - }), - ).rejects.toThrow(/capacity/); - }); - - it('releases quota on delete', async () => { - const asset = await service.registerBuffer({ - buffer: Buffer.alloc(600 * 1024, 0x41), - originalName: 'a.txt', - mimeType: 'text/plain', - size: 600 * 1024, - }); - await service.remove(asset.id); - - // Budget freed up, a new file can be stored again. - const next = await service.registerBuffer({ - buffer: textBuffer('freed'), - originalName: 'freed.txt', - mimeType: 'text/plain', - size: 5, - }); - expect(next.id).toBeDefined(); - }); - }); - - it('exposes the canonical allow-list for the controller fileFilter', () => { - expect(ALLOWED_MIME_TYPES.some((t) => t.mime === 'image/png')).toBe(true); - expect(ALLOWED_MIME_TYPES.some((t) => t.mime === 'text/' && t.prefix)).toBe(true); - }); -}); diff --git a/BackendAcademy/src/assets/assets.service.ts b/BackendAcademy/src/assets/assets.service.ts deleted file mode 100644 index c2ba12b61c..0000000000 --- a/BackendAcademy/src/assets/assets.service.ts +++ /dev/null @@ -1,650 +0,0 @@ -import { - BadRequestException, - Injectable, - Logger, - NotFoundException, - OnModuleDestroy, -} from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { promises as fs, createReadStream, type ReadStream } from 'node:fs'; -import * as path from 'node:path'; -import { randomUUID, createHash } from 'node:crypto'; -import type { - Asset, - AssetListResponse, - AssetSortOrder, -} from './interfaces/asset.interface'; -import { SecurityService } from '../security/security.service'; - -/** - * Default per-file cap (in bytes) when `ASSETS_MAX_SIZE_MB` is unset (10 MB). - */ -const DEFAULT_MAX_SIZE_BYTES = 10 * 1024 * 1024; - -/** - * Aggregate byte budget across all stored assets when `ASSETS_MAX_TOTAL_MB` - * is unset (1 GB). Prevents unbounded disk growth from many small uploads. - */ -const DEFAULT_MAX_TOTAL_BYTES = 1024 * 1024 * 1024; - -/** - * Maximum number of assets retained by the registry when `ASSETS_MAX_COUNT` - * is unset. Works alongside the total-byte quota as a second circuit-breaker. - */ -const DEFAULT_MAX_COUNT = 10_000; - -/** - * Maximum number of characters permitted in a stored filename. Keeps `route` - * and `fs` operations inside reasonable bounds. - */ -const MAX_FILENAME_LENGTH = 200; - -/** - * Default directory (relative to the process working directory) where - * uploaded assets are persisted when `ASSETS_UPLOAD_DIR` is unset. - */ -const DEFAULT_UPLOAD_DIR = './data/uploads'; - -/** - * Canonical allow-list of accepted asset media types. The value is the - * authoritative MIME type we store for a matching upload; the producer may - * declare a prefix (e.g. `image/`) which is satisfied by any concrete type - * in that family. - */ -export const ALLOWED_MIME_TYPES: ReadonlyArray<{ mime: string; prefix: boolean }> = [ - { mime: 'image/png', prefix: false }, - { mime: 'image/jpeg', prefix: false }, - { mime: 'image/gif', prefix: false }, - { mime: 'image/webp', prefix: false }, - { mime: 'application/pdf', prefix: false }, - { mime: 'text/', prefix: true }, -]; - -/** - * Magic-byte signatures mapped to the canonical MIME type they prove. File - * content is authoritative: a spoofed `.png` whose bytes are actually a shell - * script is rejected even though the client declared `image/png`. - */ -const SIGNATURE_TO_MIME: ReadonlyArray<{ bytes: number[]; mime: string }> = [ - // PNG - { bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], mime: 'image/png' }, - // JPEG (SOI) - { bytes: [0xff, 0xd8, 0xff], mime: 'image/jpeg' }, - // GIF87a / GIF89a - { bytes: [0x47, 0x49, 0x46, 0x38, 0x37, 0x61], mime: 'image/gif' }, - { bytes: [0x47, 0x49, 0x46, 0x38, 0x39, 0x61], mime: 'image/gif' }, - // WEBP (RIFF....WEBP) - { bytes: [0x52, 0x49, 0x46, 0x46, undefined as never, undefined as never, undefined as never, undefined as never, 0x57, 0x45, 0x42, 0x50], mime: 'image/webp' }, - // PDF - { bytes: [0x25, 0x50, 0x44, 0x46], mime: 'application/pdf' }, -]; - -/** - * Signatures that are *never* acceptable as uploaded assets regardless of any - * declared type — executables, archives and common script/bytecode payloads. - */ -const FORBIDDEN_SIGNATURES: ReadonlyArray<{ bytes: number[]; label: string }> = [ - { bytes: [0x4d, 0x5a], label: 'executable (MZ/PE)' }, // Windows EXE/DLL - { bytes: [0x7f, 0x45, 0x4c, 0x46], label: 'ELF binary' }, // Linux binary - { bytes: [0xca, 0xfe, 0xba, 0xbe], label: 'Java class' }, - { bytes: [0x50, 0x4b, 0x03, 0x04], label: 'ZIP/Office archive' }, // zip, docx, jar… - { bytes: [0x50, 0x4b, 0x05, 0x06], label: 'ZIP empty archive' }, - { bytes: [0x1f, 0x8b], label: 'gzip archive' }, - { bytes: [0x42, 0x5a, 0x68], label: 'bzip2 archive' }, - { bytes: [0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c], label: '7z archive' }, - { bytes: [0xfd, 0x37, 0x7a, 0x58, 0x5a, 0x00], label: 'xz archive' }, - { bytes: [0x75, 0x73, 0x74, 0x61, 0x72], label: 'tar archive' }, - { bytes: [0xcf, 0xfa, 0xed, 0xfe], label: 'Mach-O binary' }, // macOS -]; - -/** - * Text content patterns that are dangerous even though they look like plain - * text (HTML with script, SVG with script, PHP/JS source, shell shebang). - */ -const DANGEROUS_TEXT_PATTERNS: ReadonlyArray = [ - /]/i, - /<\/?\s*(svg|iframe|object|embed|link|meta)\b/i, - /on\w+\s*=\s*["']?javascript:/i, - /<\?php/i, - / = { - 'image/png': '.png', - 'image/jpeg': '.jpg', - 'image/gif': '.gif', - 'image/webp': '.webp', - 'application/pdf': '.pdf', -}; - -function isTextMime(mime: string): boolean { - return mime.startsWith('text/'); -} - -/** - * Service responsible for persisting uploaded assets, maintaining their - * metadata, and resolving them for download. - * - * NOTE: for the placeholder backend we keep the metadata in-memory in a - * `Map`. This means metadata is reset on every process restart. The physical - * files on disk survive restarts because they are evicted via - * `cleanupOrphanedFiles` on module shutdown. - * - * Security (Issue #365 / asset hardening): - * - uploads are rejected if they exceed the per-file or aggregate byte quota; - * - the real content type is established from file signature (magic bytes), - * never from the client-supplied MIME string; - * - the declared type must be allow-listed and must agree with the detected - * type, and the stored extension is derived from the detected type; - * - dangerous payloads (executables, archives, scripts, HTML/SVG) are - * rejected before any bytes touch disk; - * - filenames are sanitised and de-duplicated against the asset id. - */ -@Injectable() -export class AssetsService implements OnModuleDestroy { - private readonly logger = new Logger(AssetsService.name); - private readonly uploadDir: string; - private readonly baseUrl: string; - private readonly maxSizeBytes: number; - private readonly maxTotalBytes: number; - private readonly maxCount: number; - private readonly registry = new Map(); - /** Maps content hash → asset id for deduplication. */ - private readonly contentHashIndex = new Map(); - /** Running sum of stored asset bytes for the aggregate quota. */ - private totalBytes = 0; - - constructor( - private readonly configService: ConfigService, - private readonly securityService: SecurityService, - ) { - this.uploadDir = path.resolve( - this.configService.get('ASSETS_UPLOAD_DIR') ?? DEFAULT_UPLOAD_DIR, - ); - this.baseUrl = - this.configService.get('ASSETS_BASE_URL') ?? '/api/v1/assets'; - this.maxSizeBytes = this.resolveMaxSizeBytes(); - this.maxTotalBytes = this.resolveMaxTotalBytes(); - this.maxCount = this.resolveMaxCount(); - - // Eagerly create the upload directory so first uploads do not race. - void fs - .mkdir(this.uploadDir, { recursive: true }) - .catch((err: unknown) => - this.logger.error( - `Failed to prepare upload directory ${this.uploadDir}: ${this.toMessage(err)}`, - ), - ); - } - - /** - * Returns the list of all stored assets, optionally sorted. - * - * @param sort Sorting order; defaults to `newest`. - */ - list(sort: AssetSortOrder = 'newest'): AssetListResponse { - const assets = Array.from(this.registry.values()); - - const sorted = [...assets].sort((a, b) => { - switch (sort) { - case 'oldest': - return a.uploadedAt.localeCompare(b.uploadedAt); - case 'name': - return a.originalName.localeCompare(b.originalName); - case 'newest': - default: - return b.uploadedAt.localeCompare(a.uploadedAt); - } - }); - - return { total: sorted.length, assets: sorted }; - } - - /** - * Returns the metadata of a single asset. - * - * @throws NotFoundException when no asset with the given id exists. - */ - findById(id: string): Asset { - const asset = this.registry.get(id); - if (!asset) { - throw new NotFoundException(`Asset '${id}' not found`); - } - return asset; - } - - /** - * Returns a read stream for the given asset. Used by the controller to - * implement the download endpoint. - * - * @throws NotFoundException when the asset's metadata is missing or the - * physical file is missing on disk. - */ - async openReadStream(id: string): Promise { - const asset = this.findById(id); - const fullPath = this.resolveOnDiskPath(asset.filename); - try { - await fs.access(fullPath); - } catch (err: unknown) { - throw new NotFoundException( - `Asset '${id}' file is missing on disk: ${this.toMessage(err)}`, - ); - } - return createReadStream(fullPath); - } - - /** - * Registers a freshly-uploaded file (already buffered in memory by - * multer's `memoryStorage`) in the metadata registry, persisting the - * buffer to the managed upload directory. - * - * The buffer is inspected for its true content type and screened for - * dangerous payloads *before* it is written, so spoofed or hostile files - * are rejected without touching disk. - * - * @param buffer Bytes of the upload. - * @param originalName Original filename from the client (untrusted). - * @param mimeType Detected MIME type (untrusted — re-derived here). - * @param size Size in bytes uploaded. - * @param name Optional human-friendly name. - * @param description Optional description. - */ - async registerBuffer(params: { - buffer: Buffer; - originalName: string; - mimeType: string; - size: number; - name?: string; - description?: string; - }): Promise { - const declaredMime = (params.mimeType || 'application/octet-stream').toLowerCase(); - - // 1. Cheap pre-checks (size) before any inspection work. - this.assertSizeAllowed(params.size); - - // 2. Establish the real type from the bytes and screen the content. - const detectedMime = await this.detectMime(params.buffer); - this.assertAllowedType(detectedMime, declaredMime); - this.assertNotDangerous(params.buffer, detectedMime); - - // 3. Aggregate quotas (total bytes + count) before persisting. - this.assertQuotaAllows(params.size); - - const contentHash = this.securityService.computeContentHash(params.buffer); - - const existingId = this.contentHashIndex.get(contentHash); - if (existingId) { - this.logger.log( - `Duplicate upload detected (hash=${contentHash.slice(0, 12)}…), returning existing asset ${existingId}`, - ); - return this.findById(existingId); - } - - const id = randomUUID(); - const safeName = this.hashFilename(contentHash, id, detectedMime); - const finalPath = this.resolveOnDiskPath(safeName); - - try { - await fs.writeFile(finalPath, params.buffer); - } catch (err: unknown) { - throw new BadRequestException( - `Failed to persist uploaded asset: ${this.toMessage(err)}`, - ); - } - - const sanitisedOriginal = this.sanitizeDisplayName(params.originalName); - - const asset: Asset = { - id, - filename: safeName, - originalName: sanitisedOriginal, - mimeType: detectedMime, - size: params.size, - uploadedAt: new Date().toISOString(), - url: this.buildDownloadUrl(id), - name: params.name, - description: params.description, - contentHash, - }; - - this.registry.set(id, asset); - this.contentHashIndex.set(contentHash, id); - this.totalBytes += params.size; - return asset; - } - - /** - * Removes an asset's metadata and (best-effort) its file from disk. - * - * @throws NotFoundException when the asset cannot be found. - */ - async remove(id: string): Promise { - const asset = this.findById(id); - this.registry.delete(id); - if (asset.contentHash) { - this.contentHashIndex.delete(asset.contentHash); - } - this.totalBytes = Math.max(0, this.totalBytes - asset.size); - - const fullPath = this.resolveOnDiskPath(asset.filename); - try { - await fs.unlink(fullPath); - } catch (err: unknown) { - const error = err as NodeJS.ErrnoException; - if (error?.code !== 'ENOENT') { - this.logger.warn( - `Failed to unlink asset file ${fullPath}: ${this.toMessage(err)}`, - ); - } - // swallow ENOENT — file already missing is harmless. - } - } - - /** - * On graceful shutdown, prune any files left in the upload directory - * which are not referenced by the registry. This protects against - * orphaned tmp files from aborted uploads between restarts. - */ - async onModuleDestroy(): Promise { - try { - const entries = await fs.readdir(this.uploadDir); - await Promise.all( - entries.map(async (entry) => { - if (this.isManagedFilename(entry)) return; - const target = path.join(this.uploadDir, entry); - try { - await fs.unlink(target); - } catch (err: unknown) { - this.logger.warn( - `Failed to remove orphaned asset ${target}: ${this.toMessage(err)}`, - ); - } - }), - ); - } catch (err: unknown) { - this.logger.warn( - `Asset cleanup on shutdown failed: ${this.toMessage(err)}`, - ); - } - } - - /** Exposed for the controller when constructing multer storage options. */ - getUploadDir(): string { - return this.uploadDir; - } - - /** Exposed for the controller when announcing upload limits. */ - getMaxSizeBytes(): number { - return this.maxSizeBytes; - } - - /** Exposed for the controller when assembling multer `fileFilter`. */ - getAllowedMimeTypes(): ReadonlyArray<{ mime: string; prefix: boolean }> { - return ALLOWED_MIME_TYPES; - } - - /** Exposed for tests so the in-memory registry can be cleared. */ - clearRegistryForTests(): void { - this.registry.clear(); - this.contentHashIndex.clear(); - this.totalBytes = 0; - } - - // --------------------------------------------------------------------------- - // Internal helpers - // --------------------------------------------------------------------------- - - /** - * Inspects the leading bytes of a buffer to determine the authoritative - * content type. The magic-byte table is exact and dependency-free and - * already enumerates every binary type this service accepts (PNG, JPEG, - * GIF, WEBP, PDF) plus the dangerous families it must reject. - */ - private async detectMime(buffer: Buffer): Promise { - // Explicit magic-byte table first — it is exact and dependency-free. - const sig = this.matchSignature(buffer, SIGNATURE_TO_MIME); - if (sig) return sig; - - // Any forbidden signature (executable, archive, …) is reported as a - // non-allow-listed type so the caller rejects it uniformly. - const forbidden = this.matchForbidden(buffer); - if (forbidden) { - return `application/x-forbidden-${forbidden.label.replace(/[^a-z0-9]/gi, '-')}`; - } - - // No recognizable binary signature. Treat as opaque/denied unless the - // caller explicitly declared a (text) type, which is screened separately. - return 'application/octet-stream'; - } - - private matchSignature( - buffer: Buffer, - table: ReadonlyArray<{ bytes: number[]; mime: string }>, - ): string | undefined { - for (const { bytes, mime } of table) { - if (this.startsWithBytes(buffer, bytes)) { - return mime; - } - } - return undefined; - } - - private matchForbidden( - buffer: Buffer, - ): { label: string } | undefined { - for (const entry of FORBIDDEN_SIGNATURES) { - if (this.startsWithBytes(buffer, entry.bytes)) { - return { label: entry.label }; - } - } - return undefined; - } - - private startsWithBytes(buffer: Buffer, pattern: number[]): boolean { - if (buffer.length < pattern.length) return false; - for (let i = 0; i < pattern.length; i++) { - const expected = pattern[i]; - if (expected === (undefined as never)) continue; // wildcard byte - if (buffer[i] !== expected) return false; - } - return true; - } - - /** - * Rejects content whose real type is not allow-listed, or whose declared - * type does not match the detected type (spoofing guard). - */ - private assertAllowedType(detectedMime: string, declaredMime: string): void { - const allowed = ALLOWED_MIME_TYPES.some(({ mime, prefix }) => - prefix ? detectedMime.startsWith(mime) : detectedMime === mime, - ); - - if (!allowed) { - throw new BadRequestException( - `Asset content type '${detectedMime}' is not an allowed type`, - ); - } - - // The client's declared type must not contradict what the bytes prove. - const declaredMatches = ALLOWED_MIME_TYPES.some(({ mime, prefix }) => - prefix ? declaredMime.startsWith(mime) : declaredMime === mime, - ); - if (!declaredMatches) { - throw new BadRequestException( - `Declared type '${declaredMime}' is not allowed`, - ); - } - } - - /** - * Screens the bytes for dangerous payloads. Text content is checked for - * markup/script patterns and validated as UTF-8; binary content has - * already been limited to allow-listed image/PDF types by the caller. - */ - private assertNotDangerous(buffer: Buffer, detectedMime: string): void { - if (!isTextMime(detectedMime)) { - // Non-text (image/pdf) — allowed types are inert, nothing more to screen. - return; - } - - // Reject content that is not valid UTF-8 text. - if (!this.isUtf8(buffer)) { - throw new BadRequestException('Asset text content is not valid UTF-8'); - } - - const sample = buffer.toString('utf-8', 0, Math.min(buffer.length, 1_000_000)); - for (const pattern of DANGEROUS_TEXT_PATTERNS) { - if (pattern.test(sample)) { - throw new BadRequestException( - 'Asset contains potentially dangerous markup and was rejected', - ); - } - } - } - - private isUtf8(buffer: Buffer): boolean { - try { - buffer.toString('utf-8'); - // Ensure there are no replacement characters from bad sequences. - return !buffer.toString('utf-8').includes('�'); - } catch { - return false; - } - } - - private resolveOnDiskPath(filename: string): string { - const resolved = path.resolve(this.uploadDir, filename); - // Guard against `../` traversal: the resolved path must live *inside* - // the upload directory (with a separator so `uploadDir` itself plus - // `uploadDir-evil` cannot collide). - if (resolved !== this.uploadDir && !resolved.startsWith(this.uploadDir + path.sep)) { - throw new BadRequestException('Invalid asset filename'); - } - return resolved; - } - - private buildDownloadUrl(id: string): string { - const base = this.baseUrl.replace(/\/+$/, ''); - return `${base}/${id}/download`; - } - - /** - * Generates a signed download URL for an asset with the given scope and TTL. - */ - generateSignedDownloadUrl( - assetId: string, - scope: 'read' | 'write' | 'admin', - userId?: string, - ttlSeconds?: number, - ): string { - this.findById(assetId); - return this.securityService.generateSignedUrl({ - assetId, - scope, - userId, - ttlSeconds, - }); - } - - private resolveMaxSizeBytes(): number { - const configured = this.configService.get('ASSETS_MAX_SIZE_MB'); - const mb = configured ? Number(configured) : 10; - if (!Number.isFinite(mb) || mb <= 0) { - return DEFAULT_MAX_SIZE_BYTES; - } - return Math.floor(mb * 1024 * 1024); - } - - private resolveMaxTotalBytes(): number { - const configured = this.configService.get('ASSETS_MAX_TOTAL_MB'); - const mb = configured ? Number(configured) : 1024; - if (!Number.isFinite(mb) || mb <= 0) { - return DEFAULT_MAX_TOTAL_BYTES; - } - return Math.floor(mb * 1024 * 1024); - } - - private resolveMaxCount(): number { - const configured = this.configService.get('ASSETS_MAX_COUNT'); - const count = configured ? Number(configured) : DEFAULT_MAX_COUNT; - if (!Number.isFinite(count) || count <= 0) { - return DEFAULT_MAX_COUNT; - } - return Math.floor(count); - } - - private assertSizeAllowed(size: number): void { - if (size <= 0) { - throw new BadRequestException('Uploaded asset is empty'); - } - if (size > this.maxSizeBytes) { - throw new BadRequestException( - `Asset exceeds maximum size of ${this.maxSizeBytes} bytes`, - ); - } - } - - /** - * Enforces the aggregate quota (total stored bytes and asset count) so a - * stream of small uploads cannot exhaust disk. - */ - private assertQuotaAllows(incomingSize: number): void { - if (this.registry.size + 1 > this.maxCount) { - throw new BadRequestException( - `Asset store is at capacity (max ${this.maxCount} assets)`, - ); - } - if (this.totalBytes + incomingSize > this.maxTotalBytes) { - throw new BadRequestException( - `Asset store quota exceeded (max ${this.maxTotalBytes} bytes)`, - ); - } - } - - private sanitizeDisplayName(original: string): string { - const cleaned = (original || 'upload') - .replace(/[\u0000-\u001f\u007f]/g, '') // control chars - .replace(/["\\]/g, '') // quoting / path hazards - .replace(/^\.+/, '') // leading dots - .trim(); - return cleaned.length > MAX_FILENAME_LENGTH - ? cleaned.slice(0, MAX_FILENAME_LENGTH) - : cleaned || 'upload'; - } - - /** - * Generates a deterministic, collision-resistant filename from the content - * hash and asset id. The extension is derived from the *detected* MIME type, - * never from the client-supplied name, so a `.png` spoofed as `.exe` is - * stored with a safe `.png` extension. - */ - private hashFilename( - contentHash: string, - id: string, - detectedMime: string, - ): string { - const extension = - EXTENSION_BY_MIME[detectedMime] ?? - (isTextMime(detectedMime) ? '.txt' : '.bin'); - const hashPrefix = contentHash.slice(0, 16); - const base = `asset-${hashPrefix}-${id}${extension}`; - return base.length > MAX_FILENAME_LENGTH - ? `asset-${hashPrefix}${extension}`.slice(0, MAX_FILENAME_LENGTH) - : base; - } - - private isManagedFilename(entry: string): boolean { - for (const asset of this.registry.values()) { - if (asset.filename === entry) return true; - } - return false; - } - - private toMessage(err: unknown): string { - if (err instanceof Error) return err.message; - return String(err); - } -} diff --git a/BackendAcademy/src/assets/dto/upload-asset.dto.ts b/BackendAcademy/src/assets/dto/upload-asset.dto.ts deleted file mode 100644 index 47f0eed350..0000000000 --- a/BackendAcademy/src/assets/dto/upload-asset.dto.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { IsOptional, IsString, MaxLength } from 'class-validator'; - -/** - * Optional metadata supplied alongside a multipart file upload. - * - * The actual file is captured separately via `@UploadedFile()`; this DTO - * only validates the accompanying text fields. - * - * Note: the global `ValidationPipe` is configured with - * `forbidNonWhitelisted: true`, so adding unlisted multipart fields will - * cause the request to be rejected — that is the desired behaviour. - */ -export class UploadAssetDto { - /** - * Optional human-friendly display name for the asset. - */ - @IsOptional() - @IsString() - @MaxLength(120) - name?: string; - - /** - * Optional longer description for the asset. - */ - @IsOptional() - @IsString() - @MaxLength(500) - description?: string; -} diff --git a/BackendAcademy/src/assets/interfaces/asset.interface.ts b/BackendAcademy/src/assets/interfaces/asset.interface.ts deleted file mode 100644 index e0d1ee1569..0000000000 --- a/BackendAcademy/src/assets/interfaces/asset.interface.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Metadata describing a single stored asset (either uploaded via POST /assets - * or located under the public static-asset directory). - */ -export interface Asset { - /** Stable opaque identifier for the asset (UUID v4). */ - id: string; - /** - * Filename on disk (or relative path under the static directory). - * This is the value used to look the file up at serve time. - */ - filename: string; - /** Original filename as supplied by the client at upload time. */ - originalName: string; - /** Detected/stored MIME type of the asset (e.g. `image/png`). */ - mimeType: string; - /** Size of the asset in bytes. */ - size: number; - /** ISO 8601 timestamp the asset was registered. */ - uploadedAt: string; - /** - * Public URL where the asset content can be downloaded. - * For uploaded assets this resolves to the REST download endpoint. - */ - url: string; - /** Optional human-friendly name supplied by the uploader. */ - name?: string; - /** Optional description supplied by the uploader. */ - description?: string; - /** SHA-256 content hash of the uploaded file bytes. */ - contentHash: string; -} - -/** - * Response shape for `GET /assets`. - */ -export interface AssetListResponse { - /** Total number of assets returned. */ - total: number; - /** List of asset metadata records. */ - assets: Asset[]; -} - -/** - * Sort order for `GET /assets`. - * - `newest` (default): most recently uploaded first - * - `oldest`: oldest first - * - `name`: alphabetical by originalName - */ -export type AssetSortOrder = 'newest' | 'oldest' | 'name'; diff --git a/BackendAcademy/src/audit/audit.controller.ts b/BackendAcademy/src/audit/audit.controller.ts deleted file mode 100644 index ab47b477dc..0000000000 --- a/BackendAcademy/src/audit/audit.controller.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Controller, Get } from '@nestjs/common'; - -@Controller('audit') -export class AuditController { - @Get() - health() { - return { status: 'ok' }; - } -} diff --git a/BackendAcademy/src/audit/audit.module.ts b/BackendAcademy/src/audit/audit.module.ts deleted file mode 100644 index 92ca6d6b31..0000000000 --- a/BackendAcademy/src/audit/audit.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Module } from '@nestjs/common'; -import { AuditLogModule } from './logging/audit-log.module'; -import { AuditController } from './audit.controller'; - -@Module({ - imports: [ - AuditLogModule, - ], - controllers: [AuditController], - exports: [AuditLogModule] -}) -export class AuditModule {} diff --git a/BackendAcademy/src/audit/audit.service.ts b/BackendAcademy/src/audit/audit.service.ts deleted file mode 100644 index f5b8c93ec5..0000000000 --- a/BackendAcademy/src/audit/audit.service.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { CorrelationLoggerService } from '../logging/logger.service'; - -export interface AuditEvent { - action: string; - actor?: string; - outcome: 'SUCCESS' | 'FAILURE'; - session?: string; - correlation?: string; - requestContext?: Record; - metadata?: Record; -} - -export interface AuditLog extends AuditEvent { - id: string; - timestamp: Date; -} - -@Injectable() -export class AuditLogService { - private readonly logs: AuditLog[] = []; - private readonly logger = new Logger(AuditLogService.name); - - constructor() {} - - create(event: AuditEvent): AuditLog { - const sanitizedContext = this.sanitize(event.requestContext); - const correlation = event.correlation || CorrelationLoggerService.getCorrelationId(); - - const log: AuditLog = { - id: crypto.randomUUID(), - ...event, - correlation, - requestContext: sanitizedContext, - timestamp: new Date(), - }; - - this.logs.push(log); - - // Provide structured output to the standard logger without secrets - this.logger.log( - `Audit: ${event.action} | Actor: ${event.actor ?? '-'} | Outcome: ${event.outcome} | Session: ${event.session ?? '-'} | Correlation: ${correlation}`, - ); - - return log; - } - - private sanitize(ctx?: Record): Record | undefined { - if (!ctx) return undefined; - const sanitized = { ...ctx }; - const secrets = ['password', 'token', 'secret', 'authorization', 'cookie', 'refreshtoken', 'accesstoken']; - for (const key of Object.keys(sanitized)) { - if (secrets.some((s) => key.toLowerCase().includes(s))) { - sanitized[key] = '[REDACTED]'; - } - } - return sanitized; - } - - findAll(): AuditLog[] { - return this.logs.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); - } - - findByUser(user: string): AuditLog[] { - return this.logs.filter((log) => log.actor === user); - } - - clear(): void { - this.logs.length = 0; - } -} diff --git a/BackendAcademy/src/audit/logging/audit-log.module.ts b/BackendAcademy/src/audit/logging/audit-log.module.ts deleted file mode 100644 index a5b159ea84..0000000000 --- a/BackendAcademy/src/audit/logging/audit-log.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Module } from '@nestjs/common'; -import { AuditLogService } from '../audit.service'; - -@Module({ - providers: [AuditLogService], - exports: [AuditLogService], -}) -export class AuditLogModule {} diff --git a/BackendAcademy/src/auth/auth-session.controller.ts b/BackendAcademy/src/auth/auth-session.controller.ts deleted file mode 100644 index 8c61f9ee32..0000000000 --- a/BackendAcademy/src/auth/auth-session.controller.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { - Body, - Controller, - Delete, - Get, - HttpCode, - HttpStatus, - Param, - Post, - Query, -} from '@nestjs/common'; -import { AuthSessionService } from './auth-session.service'; -import { LoginDto } from './dto/login.dto'; -import { RefreshTokenDto } from './dto/refresh-token.dto'; -import { AuthTokensResponse, Session } from './interfaces/session.interface'; -import { UserRole } from './enums/user-role.enum'; -import { AntiCheatService } from '../security/anti-cheat.service'; - -export class HeartbeatDto { - sessionId: string; -} - -@Controller('auth/session') -export class AuthSessionController { - constructor( - private readonly authSessionService: AuthSessionService, - private readonly antiCheatService: AntiCheatService, - ) {} - - @Post('login') - @HttpCode(HttpStatus.CREATED) - async login(@Body() dto: LoginDto): Promise { - return this.authSessionService.createSession( - dto.userId, - dto.role as UserRole, - ); - } - - @Post('refresh') - @HttpCode(HttpStatus.OK) - async refresh(@Body() dto: RefreshTokenDto): Promise { - return this.authSessionService.refreshTokens(dto.refreshToken); - } - - @Post('heartbeat') - @HttpCode(HttpStatus.OK) - async heartbeat(@Body() dto: HeartbeatDto): Promise<{ updated: boolean }> { - await this.authSessionService.updateLastActivity(dto.sessionId); - return { updated: true }; - } - - @Post('logout') - @HttpCode(HttpStatus.NO_CONTENT) - async logout(@Query('sessionId') sessionId: string): Promise { - await this.authSessionService.revokeSession(sessionId); - } - - @Post('logout-all') - @HttpCode(HttpStatus.NO_CONTENT) - async logoutAll(@Query('userId') userId: string): Promise { - await this.authSessionService.revokeAllUserSessions(userId); - } - - @Get(':userId') - @HttpCode(HttpStatus.OK) - getActiveSessions( - @Param('userId') userId: string, - ): Omit[] { - return this.authSessionService.getActiveSessions(userId); - } - - // --------------------------------------------------------------------------- - // API Key Issuance & Revocation — Issue #410 - // --------------------------------------------------------------------------- - - @Post('api-keys') - @HttpCode(HttpStatus.CREATED) - createApiKey( - @Body() body: { userId: string; label: string; scopes?: string[]; expiresInDays?: number }, - ): { id: string; rawKey: string } { - return this.antiCheatService.createApiKey( - body.userId, - body.label, - body.scopes, - body.expiresInDays, - ); - } - - @Get('api-keys/:userId') - @HttpCode(HttpStatus.OK) - listApiKeys(@Param('userId') userId: string) { - return this.antiCheatService.getUserApiKeys(userId); - } - - @Post('api-keys/:keyId/revoke') - @HttpCode(HttpStatus.OK) - revokeApiKey(@Param('keyId') keyId: string) { - const revoked = this.antiCheatService.revokeApiKey(keyId); - if (!revoked) { - return { success: false, message: 'Key not found' }; - } - return { success: true, message: 'API key revoked' }; - } - - @Post('api-keys/:keyId/rotate') - @HttpCode(HttpStatus.CREATED) - rotateApiKey(@Param('keyId') keyId: string) { - const newKey = this.antiCheatService.rotateApiKey(keyId); - if (!newKey) { - return { success: false, message: 'Key not found' }; - } - return { success: true, ...newKey }; - } -} diff --git a/BackendAcademy/src/auth/auth-session.service.spec.ts b/BackendAcademy/src/auth/auth-session.service.spec.ts deleted file mode 100644 index 2859802cbe..0000000000 --- a/BackendAcademy/src/auth/auth-session.service.spec.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { UnauthorizedException } from '@nestjs/common'; -import { AuthSessionService } from './auth-session.service'; -import { UserRole } from './enums/user-role.enum'; -import { Session } from './interfaces/session.interface'; -import { RedisService } from '../redis/redis.service'; -import { AuditLogService } from '../audit/audit.service'; - -describe('AuthSessionService security revocation', () => { - let service: AuthSessionService; - let revokeAllUserSessions: jest.SpyInstance; - - beforeEach(() => { - const jwtService = { - verifyAsync: jest.fn(), - signAsync: jest.fn(), - }; - const configService = { - get: jest.fn((_key: string, fallback: unknown) => fallback), - }; - - service = new AuthSessionService( - jwtService as never, - configService as never, - new RedisService(), - new AuditLogService(), - ); - revokeAllUserSessions = jest.spyOn(service, 'revokeAllUserSessions'); - }); - - it.each([ - ['password change', () => service.onPasswordChanged('user-1')], - ['password reset', () => service.onPasswordReset('user-1')], - ['privilege change', () => service.onPrivilegeChanged('user-1')], - ['account deletion', () => service.onAccountDeleted('user-1')], - ])('revokes sessions after %s', async (_event, revoke) => { - await revoke(); - - expect(revokeAllUserSessions).toHaveBeenCalledWith('user-1', expect.any(String)); - }); - - it('revokes every session after refresh-token reuse', async () => { - const now = Date.now(); - const sessionFactory = (sessionId: string): Session => ({ - sessionId, - userId: 'user-1', - role: UserRole.LEARNER, - refreshTokenHash: 'hash-of-different-token', - createdAt: new Date(now), - expiresAt: new Date(now + 60_000), - absoluteExpiresAt: new Date(now + 60_000 + 300_000), - idleExpiresAt: new Date(now + 86_400_000), - deliveryGraceSeconds: 300, - lastActivityAt: new Date(now), - revoked: false, - }); - const redis = (service as unknown as { redis: RedisService }).redis; - await redis.set('session:session-1', JSON.stringify(sessionFactory('session-1'))); - await redis.set('session:session-2', JSON.stringify(sessionFactory('session-2'))); - await redis.sadd('userSessions:user-1', 'session-1'); - await redis.sadd('userSessions:user-1', 'session-2'); - - const jwtService = (service as unknown as { jwtService: { verifyAsync: jest.Mock } }).jwtService; - jwtService.verifyAsync.mockResolvedValue({ - sub: 'user-1', - role: UserRole.LEARNER, - sessionId: 'session-1', - }); - - await expect(service.refreshTokens('replayed-token')).rejects.toBeInstanceOf( - UnauthorizedException, - ); - - expect(await service.getActiveSessions('user-1')).toHaveLength(0); - expect(revokeAllUserSessions).toHaveBeenCalledWith('user-1', 'token_reuse'); - }); - - it('updates lastActivityAt on valid activity', async () => { - const now = Date.now(); - const session: Session = { - sessionId: 'session-1', - userId: 'user-1', - role: UserRole.LEARNER, - refreshTokenHash: 'hash', - createdAt: new Date(now), - expiresAt: new Date(now + 60_000), - absoluteExpiresAt: new Date(now + 300_000), - idleExpiresAt: new Date(now + 86_400_000), - deliveryGraceSeconds: 300, - lastActivityAt: new Date(now), - revoked: false, - }; - const redis = (service as unknown as { redis: RedisService }).redis; - await redis.set('session:session-1', JSON.stringify(session)); - await redis.sadd('userSessions:user-1', 'session-1'); - - await service.validateSession('session-1'); - - const stored = JSON.parse(await redis.get('session:session-1') as string) as Session; - expect(stored.revoked).toBe(false); - expect(new Date(stored.lastActivityAt).getTime()).toBeGreaterThanOrEqual(now); - }); - - it('revokes idle sessions on validation', async () => { - const now = Date.now(); - const session: Session = { - sessionId: 'session-1', - userId: 'user-1', - role: UserRole.LEARNER, - refreshTokenHash: 'hash', - createdAt: new Date(now - 100_000), - expiresAt: new Date(now + 60_000), - absoluteExpiresAt: new Date(now + 300_000), - idleExpiresAt: new Date(now - 10_000), - deliveryGraceSeconds: 300, - lastActivityAt: new Date(now - 90_000_000), - revoked: false, - }; - const redis = (service as unknown as { redis: RedisService }).redis; - await redis.set('session:session-1', JSON.stringify(session)); - await redis.sadd('userSessions:user-1', 'session-1'); - - await expect(service.validateSession('session-1')).rejects.toBeInstanceOf( - UnauthorizedException, - ); - - const stored = JSON.parse(await redis.get('session:session-1') as string) as Session; - expect(stored.revoked).toBe(true); - }); -}); diff --git a/BackendAcademy/src/auth/auth-session.service.ts b/BackendAcademy/src/auth/auth-session.service.ts deleted file mode 100644 index 131f20569c..0000000000 --- a/BackendAcademy/src/auth/auth-session.service.ts +++ /dev/null @@ -1,562 +0,0 @@ -import { - Injectable, - UnauthorizedException, - Logger, -} from '@nestjs/common'; -import { JwtService } from '@nestjs/jwt'; -import { AuditLogService } from '../audit/audit.service'; -import { ConfigService } from '@nestjs/config'; -import { randomUUID, createHash } from 'crypto'; -import { UserRole } from './enums/user-role.enum'; -import { JwtPayload } from './interfaces/jwt-payload.interface'; -import { - RefreshTokenPayload, - AuthTokensResponse, - Session, -} from './interfaces/session.interface'; -import { RedisService } from '../redis/redis.service'; - -export interface SessionPolicy { - accessTokenTtl: number; - refreshTokenTtl: number; - deliveryGracePeriod: number; - maxConcurrentSessions: number; - singleSessionMode: boolean; - requireDeviceFingerprint: boolean; - idleSessionTimeout: number; -} - -const DEFAULT_SESSION_POLICY: SessionPolicy = { - accessTokenTtl: 900, - refreshTokenTtl: 604_800, - deliveryGracePeriod: 300, - maxConcurrentSessions: 5, - singleSessionMode: false, - requireDeviceFingerprint: false, - idleSessionTimeout: 86_400, -}; - -@Injectable() -export class AuthSessionService { - private readonly logger = new Logger(AuthSessionService.name); - private readonly sessionPolicy: SessionPolicy; - private readonly refreshLocks = new Map>(); - private readonly accessSecret: string; - private readonly refreshSecret: string; - - constructor( - private readonly jwtService: JwtService, - private readonly configService: ConfigService, - private readonly redis: RedisService, - private readonly auditService: AuditLogService, - ) { - this.sessionPolicy = { - accessTokenTtl: this.configService.get( - 'SESSION_ACCESS_TOKEN_TTL', - DEFAULT_SESSION_POLICY.accessTokenTtl, - ), - refreshTokenTtl: this.configService.get( - 'SESSION_REFRESH_TOKEN_TTL', - DEFAULT_SESSION_POLICY.refreshTokenTtl, - ), - deliveryGracePeriod: this.configService.get( - 'SESSION_DELIVERY_GRACE_PERIOD', - DEFAULT_SESSION_POLICY.deliveryGracePeriod, - ), - maxConcurrentSessions: this.configService.get( - 'SESSION_MAX_CONCURRENT', - DEFAULT_SESSION_POLICY.maxConcurrentSessions, - ), - singleSessionMode: this.configService.get( - 'SESSION_SINGLE_MODE', - DEFAULT_SESSION_POLICY.singleSessionMode, - ), - requireDeviceFingerprint: this.configService.get( - 'SESSION_REQUIRE_DEVICE', - DEFAULT_SESSION_POLICY.requireDeviceFingerprint, - ), - idleSessionTimeout: this.configService.get( - 'SESSION_IDLE_TIMEOUT', - DEFAULT_SESSION_POLICY.idleSessionTimeout, - ), - }; - - this.accessSecret = this.configService.get( - 'JWT_ACCESS_SECRET', - 'default-access-secret', - ); - this.refreshSecret = this.configService.get( - 'JWT_REFRESH_SECRET', - 'default-refresh-secret', - ); - } - - private hashToken(token: string): string { - return createHash('sha256').update(token).digest('hex'); - } - - getSessionPolicy(): Readonly { - return { ...this.sessionPolicy }; - } - - async createSession( - userId: string, - role: UserRole, - deviceFingerprint?: string, - ): Promise { - const sessionId = randomUUID(); - const now = new Date(); - const expiresAt = new Date( - now.getTime() + this.sessionPolicy.refreshTokenTtl * 1000, - ); - const { accessToken, refreshToken } = await this.signTokenPair( - userId, - role, - sessionId, - ); - const deviceHash = deviceFingerprint - ? this.hashDevice(deviceFingerprint) - : undefined; - - if (this.sessionPolicy.singleSessionMode) { - await this.revokeAllUserSessions(userId); - } - - const activeSessions = await this.getActiveSessions(userId); - if (activeSessions.length >= this.sessionPolicy.maxConcurrentSessions) { - const oldest = activeSessions.sort( - (a, b) => a.createdAt.getTime() - b.createdAt.getTime(), - )[0]; - if (oldest) { - await this.revokeSession(oldest.sessionId); - this.logger.warn( - `Revoked oldest session ${oldest.sessionId} for user ${userId} (max concurrent: ${this.sessionPolicy.maxConcurrentSessions})`, - ); - } - } - - const session: Session = { - sessionId, - userId, - role, - refreshTokenHash: this.hashToken(refreshToken), - createdAt: now, - expiresAt, - absoluteExpiresAt: new Date( - now.getTime() + - this.sessionPolicy.refreshTokenTtl * 1000 + - this.sessionPolicy.deliveryGracePeriod * 1000, - ), - idleExpiresAt: new Date( - now.getTime() + this.sessionPolicy.idleSessionTimeout * 1000, - ), - deliveryGraceSeconds: this.sessionPolicy.deliveryGracePeriod, - lastActivityAt: now, - revoked: false, - deviceHash, - isTrustedDevice: deviceHash - ? await this.isTrustedDevice(userId, deviceHash) - : undefined, - }; - - await this.setSession(session); - if (deviceHash) { - await this.redis.sadd(`trustedDevices:${userId}`, deviceHash); - } - - if (deviceHash && !(await this.isTrustedDevice(userId, deviceHash))) { - this.logger.warn(`New device login for user ${userId}`); - } - - await this.auditService.create({ - action: 'login', - actor: userId, - outcome: 'SUCCESS', - session: sessionId, - requestContext: { deviceHash }, - }); - return this.buildTokensResponse(accessToken, refreshToken); - } - - async refreshTokens(rawRefreshToken: string): Promise { - let payload: RefreshTokenPayload; - try { - payload = await this.jwtService.verifyAsync( - rawRefreshToken, - { secret: this.refreshSecret }, - ); - } catch { - throw new UnauthorizedException({ - error: 'INVALID_REFRESH_TOKEN', - message: 'Refresh token is invalid or has expired', - }); - } - - return this.withRefreshLock(payload.sessionId, async () => { - const claimKey = `refreshClaim:${payload.sessionId}`; - const existingClaim = await this.redis.get(claimKey); - if (existingClaim) { - throw new UnauthorizedException({ - error: 'SESSION_NOT_FOUND', - message: 'Session has been revoked or does not exist', - }); - } - await this.redis.set(claimKey, randomUUID(), 30_000); - - const session = await this.getSession(payload.sessionId); - if (!session || session.revoked) { - await this.redis.del(claimKey); - throw new UnauthorizedException({ - error: 'SESSION_NOT_FOUND', - message: 'Session has been revoked or does not exist', - }); - } - - if (this.hashToken(rawRefreshToken) !== session.refreshTokenHash) { - await this.revokeAllUserSessions(session.userId, 'token_reuse'); - session.revoked = true; - await this.setSession(session); - await this.redis.del(claimKey); - throw new UnauthorizedException({ - error: 'TOKEN_REUSE_DETECTED', - message: 'Refresh token has already been used; session revoked', - }); - } - - const now = new Date(); - if (this.isSessionExpired(session, now)) { - session.revoked = true; - await this.setSession(session); - await this.redis.del(claimKey); - throw new UnauthorizedException({ - error: 'SESSION_EXPIRED', - message: 'Session has expired; please log in again', - }); - } - - if (this.isSessionIdle(session, now)) { - session.revoked = true; - await this.setSession(session); - await this.redis.del(claimKey); - throw new UnauthorizedException({ - error: 'SESSION_IDLE_TIMEOUT', - message: 'Session has been idle for too long; please log in again', - }); - } - - session.revoked = true; - await this.setSession(session); - await this.redis.del(claimKey); - - await this.auditService.create({ - action: 'refresh', - actor: session.userId, - outcome: 'SUCCESS', - session: session.sessionId, - }); - return this.createSession(session.userId, session.role); - }); - } - - async validateSession(sessionId: string): Promise { - return this.withRefreshLock(sessionId, async () => { - const session = await this.getSession(sessionId); - if (!session) { - throw new UnauthorizedException({ - error: 'SESSION_NOT_FOUND', - message: 'Session does not exist', - }); - } - if (session.revoked) { - throw new UnauthorizedException({ - error: 'SESSION_REVOKED', - message: 'Session has been revoked', - }); - } - - const now = new Date(); - if (this.isSessionExpired(session, now)) { - session.revoked = true; - await this.setSession(session); - throw new UnauthorizedException({ - error: 'SESSION_EXPIRED', - message: 'Session has expired; please log in again', - }); - } - - if (this.isSessionIdle(session, now)) { - session.revoked = true; - await this.setSession(session); - throw new UnauthorizedException({ - error: 'SESSION_IDLE_TIMEOUT', - message: 'Session has been idle for too long; please log in again', - }); - } - - const previousActivity = session.lastActivityAt - ? new Date(session.lastActivityAt).getTime() - : 0; - const newActivity = Date.now(); - if (newActivity > previousActivity) { - session.lastActivityAt = new Date(newActivity); - session.idleExpiresAt = new Date( - newActivity + this.sessionPolicy.idleSessionTimeout * 1000, - ); - await this.setSession(session); - } - - return session; - }); - } - - async validateAndRefreshSession(sessionId: string): Promise { - return this.validateSession(sessionId); - } - - async updateLastActivity(sessionId: string): Promise { - await this.validateSession(sessionId); - } - - async revokeSession(sessionId: string, reason = 'logout'): Promise { - const session = await this.getSession(sessionId); - if (session) { - session.revoked = true; - await this.setSession(session); - this.logger.log(`Session ${sessionId} revoked for user ${session.userId}`); - this.auditService.create({ - action: reason, - actor: session.userId, - outcome: 'SUCCESS', - session: sessionId, - }); - } - } - - async revokeAllUserSessions(userId: string, reason = 'logout_all'): Promise { - const sessionIds = await this.redis.smembers(`userSessions:${userId}`); - let count = 0; - for (const sessionId of sessionIds) { - const session = await this.getSession(sessionId); - if (session && !session.revoked) { - session.revoked = true; - await this.setSession(session); - count++; - } - } - this.logger.log(`All ${count} sessions revoked for user ${userId}`); - this.auditService.create({ - action: reason, - actor: userId, - outcome: 'SUCCESS', - requestContext: { count }, - }); - } - - async onPasswordChanged(userId: string): Promise { - await this.revokeAllUserSessions(userId, 'password_changed'); - } - - async onPasswordReset(userId: string): Promise { - await this.revokeAllUserSessions(userId, 'password_reset'); - } - - async onPrivilegeChanged(userId: string): Promise { - await this.revokeAllUserSessions(userId, 'privilege_changed'); - } - - async onAccountDeleted(userId: string): Promise { - await this.revokeAllUserSessions(userId, 'account_deleted'); - await this.redis.del(`userSessions:${userId}`); - await this.redis.del(`trustedDevices:${userId}`); - } - - async getActiveSessions( - userId: string, - ): Promise[]> { - const sessionIds = await this.redis.smembers(`userSessions:${userId}`); - const now = new Date(); - const result: Omit[] = []; - for (const sessionId of sessionIds) { - const session = await this.getSession(sessionId); - if ( - session && - session.userId === userId && - !session.revoked && - !this.isSessionExpired(session, now) && - !this.isSessionIdle(session, now) - ) { - const { refreshTokenHash, ...rest } = session; - result.push(rest); - } - } - return result; - } - - hashDevice(fingerprint: string): string { - return createHash('sha256').update(fingerprint).digest('hex'); - } - - async isTrustedDevice(userId: string, deviceHash: string): Promise { - const devices = await this.redis.smembers(`trustedDevices:${userId}`); - return devices.includes(deviceHash); - } - - async addTrustedDevice(userId: string, deviceHash: string): Promise { - await this.redis.sadd(`trustedDevices:${userId}`, deviceHash); - this.auditService.create({ - action: 'add_trusted_device', - actor: userId, - outcome: 'SUCCESS', - requestContext: { deviceHash }, - }); - } - - async removeTrustedDevice(userId: string, deviceHash: string): Promise { - await this.redis.srem(`trustedDevices:${userId}`, deviceHash); - this.auditService.create({ - action: 'remove_trusted_device', - actor: userId, - outcome: 'SUCCESS', - requestContext: { deviceHash }, - }); - } - - async getTrustedDevices(userId: string): Promise { - return this.redis.smembers(`trustedDevices:${userId}`); - } - - async checkDeviceTrust( - userId: string, - deviceFingerprint: string, - ): Promise<{ trusted: boolean; deviceHash: string }> { - const deviceHash = this.hashDevice(deviceFingerprint); - return { - trusted: await this.isTrustedDevice(userId, deviceHash), - deviceHash, - }; - } - - private async withRefreshLock( - sessionId: string, - operation: () => Promise, - ): Promise { - const previous = this.refreshLocks.get(sessionId) ?? Promise.resolve(); - let release!: () => void; - const current = new Promise((resolve) => { - release = resolve; - }); - const queued = previous.then(() => current); - this.refreshLocks.set(sessionId, queued); - - await previous; - try { - return await operation(); - } finally { - release(); - if (this.refreshLocks.get(sessionId) === queued) { - this.refreshLocks.delete(sessionId); - } - } - } - - private sessionKey(sessionId: string): string { - return `session:${sessionId}`; - } - - private userSessionsKey(userId: string): string { - return `userSessions:${userId}`; - } - - private async getSession(sessionId: string): Promise { - const data = await this.redis.get(this.sessionKey(sessionId)); - if (!data) return null; - const session = JSON.parse(data as string) as Session; - session.createdAt = new Date(session.createdAt); - session.expiresAt = new Date(session.expiresAt); - session.absoluteExpiresAt = new Date(session.absoluteExpiresAt); - session.idleExpiresAt = new Date(session.idleExpiresAt); - session.lastActivityAt = new Date(session.lastActivityAt); - if (session.revokedAt) { - session.revokedAt = new Date(session.revokedAt); - } - return session; - } - - private async setSession(session: Session): Promise { - const ttlSeconds = Math.max( - 1, - Math.floor( - (session.expiresAt.getTime() - Date.now()) / 1000, - ) + this.sessionPolicy.deliveryGracePeriod, - ); - await this.redis.set( - this.sessionKey(session.sessionId), - JSON.stringify(session), - ttlSeconds * 1000, - ); - await this.redis.sadd( - this.userSessionsKey(session.userId), - session.sessionId, - ); - } - - private async signTokenPair( - userId: string, - role: UserRole, - sessionId: string, - ): Promise<{ accessToken: string; refreshToken: string }> { - const accessPayload: JwtPayload = { - sub: userId, - role, - sessionId, - type: 'access', - }; - const refreshPayload: RefreshTokenPayload = { - sub: userId, - role, - sessionId, - type: 'refresh', - }; - - const [accessToken, refreshToken] = await Promise.all([ - this.jwtService.signAsync(accessPayload, { - secret: this.accessSecret, - expiresIn: this.sessionPolicy.accessTokenTtl, - }), - this.jwtService.signAsync(refreshPayload, { - secret: this.refreshSecret, - expiresIn: this.sessionPolicy.refreshTokenTtl, - }), - ]); - - return { accessToken, refreshToken }; - } - - private buildTokensResponse( - accessToken: string, - refreshToken: string, - ): AuthTokensResponse { - return { - accessToken, - refreshToken, - tokenType: 'Bearer', - expiresIn: this.sessionPolicy.accessTokenTtl, - }; - } - - private isSessionExpired(session: Session, now: Date): boolean { - const expiryWithGrace = new Date( - session.expiresAt.getTime() + - this.sessionPolicy.deliveryGracePeriod * 1000, - ); - return now > expiryWithGrace; - } - - private isSessionIdle(session: Session, now: Date): boolean { - const lastActivityAt = new Date(session.lastActivityAt); - return ( - now.getTime() - lastActivityAt.getTime() > - this.sessionPolicy.idleSessionTimeout * 1000 - ); - } -} diff --git a/BackendAcademy/src/auth/auth.module.ts b/BackendAcademy/src/auth/auth.module.ts deleted file mode 100644 index d4ac0a918d..0000000000 --- a/BackendAcademy/src/auth/auth.module.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { Module } from '@nestjs/common'; -import { JwtModule } from '@nestjs/jwt'; -import { ConfigModule, ConfigService } from '@nestjs/config'; -import { RedisModule } from '../redis/redis.module'; -import { JwtLearnerGuard } from './guards/jwt-learner.guard'; -import { JwtTutorGuard } from './guards/jwt-tutor.guard'; -import { JwtAdminGuard } from './guards/jwt-admin.guard'; -import { RolesGuard } from './guards/roles.guard'; -import { AuthSessionService } from './auth-session.service'; -import { AuthSessionController } from './auth-session.controller'; -import { AuditModule } from '../audit/audit.module'; - -@Module({ - imports: [ - ConfigModule, - RedisModule, - AuditModule, - JwtModule.registerAsync({ - imports: [ConfigModule], - useFactory: (config: ConfigService) => { - // Bounded clock skew (seconds) tolerated on token `exp`/`nbf` checks. - // Applies at verification so tokens issued by a peer whose clock is - // slightly ahead/behind are neither rejected prematurely nor accepted - // once far beyond their lifetime. - const clockSkewSeconds = config.get('JWT_CLOCK_SKEW_SECONDS', 30); - return { - secret: config.get('JWT_SECRET', 'changeme'), - signOptions: { expiresIn: '7d' }, - verifyOptions: { - clockTolerance: clockSkewSeconds, - }, - }; - }, - useFactory: (config: ConfigService) => ({ - secret: config.get('JWT_SECRET', 'changeme'), - signOptions: { expiresIn: '15m' }, - }), - inject: [ConfigService], - }), - ], - controllers: [AuthSessionController], - providers: [ - JwtLearnerGuard, - JwtTutorGuard, - JwtAdminGuard, - RolesGuard, - AuthSessionService, - ], - exports: [ - JwtModule, - JwtLearnerGuard, - JwtTutorGuard, - JwtAdminGuard, - RolesGuard, - AuthSessionService, - ], -}) -export class AuthModule {} diff --git a/BackendAcademy/src/auth/decorators/roles.decorator.ts b/BackendAcademy/src/auth/decorators/roles.decorator.ts deleted file mode 100644 index 5f04631825..0000000000 --- a/BackendAcademy/src/auth/decorators/roles.decorator.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { SetMetadata } from '@nestjs/common'; -import { UserRole } from '../enums/user-role.enum'; - -export const ROLES_KEY = 'roles'; -export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles); diff --git a/BackendAcademy/src/auth/dto/login.dto.ts b/BackendAcademy/src/auth/dto/login.dto.ts deleted file mode 100644 index 0c1df3d685..0000000000 --- a/BackendAcademy/src/auth/dto/login.dto.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { IsEnum, IsNotEmpty, IsString, MinLength } from 'class-validator'; -import { UserRole } from '../enums/user-role.enum'; - -/** - * Request body for POST /auth/session/login. - * - * In production this would validate credentials against a user store. - * Here we accept a userId + role pair so the service can be wired in - * without a full database dependency. - */ -export class LoginDto { - @IsString() - @IsNotEmpty() - userId: string; - - @IsEnum(UserRole) - role: UserRole; - - @IsString() - @IsNotEmpty() - @MinLength(6) - password: string; -} diff --git a/BackendAcademy/src/auth/dto/refresh-token.dto.ts b/BackendAcademy/src/auth/dto/refresh-token.dto.ts deleted file mode 100644 index e8bfdae6e3..0000000000 --- a/BackendAcademy/src/auth/dto/refresh-token.dto.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { IsNotEmpty, IsString } from 'class-validator'; - -/** - * Request body for POST /auth/session/refresh. - */ -export class RefreshTokenDto { - @IsString() - @IsNotEmpty() - refreshToken: string; -} diff --git a/BackendAcademy/src/auth/enums/user-role.enum.ts b/BackendAcademy/src/auth/enums/user-role.enum.ts deleted file mode 100644 index 962367d828..0000000000 --- a/BackendAcademy/src/auth/enums/user-role.enum.ts +++ /dev/null @@ -1,5 +0,0 @@ -export enum UserRole { - LEARNER = 'learner', - TUTOR = 'tutor', - ADMIN = 'admin', -} diff --git a/BackendAcademy/src/auth/guards/jwt-admin.guard.ts b/BackendAcademy/src/auth/guards/jwt-admin.guard.ts deleted file mode 100644 index 2e86de46e8..0000000000 --- a/BackendAcademy/src/auth/guards/jwt-admin.guard.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { - CanActivate, - ExecutionContext, - Injectable, - UnauthorizedException, - ForbiddenException, -} from '@nestjs/common'; -import { JwtService } from '@nestjs/jwt'; -import { Request } from 'express'; -import { JwtPayload } from '../interfaces/jwt-payload.interface'; -import { UserRole } from '../enums/user-role.enum'; -import { AuthSessionService } from '../auth-session.service'; - -/** - * Protects routes that require a valid admin JWT. - * - * Expects an `authorization: Bearer ` Header. - * The token payload must contain role: "admin". - * - * On success, attaches request.user with the decoded payload. - */ -@Injectable() -export class JwtAdminGuard implements CanActivate { - constructor( - private readonly jwtService: JwtService, - private readonly authSessionService: AuthSessionService, - ) {} - - async canActivate(context: ExecutionContext): Promise { - const request = context.switchToHttp().getRequest(); - const token = this.extractBearerToken(request); - - if (!token) { - throw new UnauthorizedException({ - error: 'MISSING_TOKEN', - message: 'Authorization header with Bearer token is required', - }); - } - - let payload: JwtPayload; - try { - payload = await this.jwtService.verifyAsync(token); - } catch { - throw new UnauthorizedException({ - error: 'INVALID_TOKEN', - message: 'Token is invalid or has expired', - }); - } - - if (payload.role !== UserRole.ADMIN) { - throw new ForbiddenException({ - error: 'ADMIN_ROLE_REQUIRED', - message: 'Only admins are allowed to access this resource', - }); - } - - // Enforce session expiration independently of JWT verification. - // This ensures absolute expiry, delivery grace, and idle timeout - // are applied even when the JWT itself is still valid. - const sessionId = - (payload as any).sessionId ?? (payload as any).jti ?? (payload as any).sid; - if (!sessionId) { - throw new UnauthorizedException({ - error: 'MISSING_SESSION', - message: 'Token does not contain a valid session identifier', - }); - } - - try { - await this.authSessionService.validateSession(sessionId); - } catch (err) { - if (err instanceof UnauthorizedException) { - throw err; - } - throw new UnauthorizedException({ - error: 'SESSION_EXPIRED', - message: 'Session has expired or is inactive', - }); - } - - // Attach decoded user identity for downstream handlers - (request as Request & { user: JwtPayload }).user = payload; - return true; - } - - private extractBearerToken(request: Request): string | undefined { - const [type, token] = request.headers.authorization?.split(' ') ?? []; - return type === 'Bearer' ? token : undefined; - } -} \ No newline at end of file diff --git a/BackendAcademy/src/auth/guards/jwt-learner.guard.ts b/BackendAcademy/src/auth/guards/jwt-learner.guard.ts deleted file mode 100644 index 78f6b8e611..0000000000 --- a/BackendAcademy/src/auth/guards/jwt-learner.guard.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { - CanActivate, - ExecutionContext, - Injectable, - UnauthorizedException, - ForbiddenException, -} from '@nestjs/common'; -import { JwtService } from '@nestjs/jwt'; -import { Request } from 'express'; -import { JwtPayload } from '../interfaces/jwt-payload.interface'; -import { UserRole } from '../enums/user-role.enum'; -import { AuthSessionService } from '../auth-session.service'; - -@Injectable() -export class JwtLearnerGuard implements CanActivate { - constructor( - private readonly jwtService: JwtService, - private readonly authSessionService: AuthSessionService, - ) {} - - async canActivate(context: ExecutionContext): Promise { - const request = context.switchToHttp().getRequest(); - const token = this.extractBearerToken(request); - - if (!token) { - throw new UnauthorizedException({ - error: 'MISSING_TOKEN', - message: 'Authorization header with Bearer token is required', - }); - } - - let payload: JwtPayload & { sessionId?: string }; - try { - payload = await this.jwtService.verifyAsync< - JwtPayload & { sessionId?: string } - >(token); - } catch { - throw new UnauthorizedException({ - error: 'INVALID_TOKEN', - message: 'Token is invalid or has expired', - }); - } - - if (payload.role !== UserRole.LEARNER) { - throw new ForbiddenException({ - error: 'LEARNER_ROLE_REQUIRED', - message: 'Only learners are allowed to access this resource', - }); - } - - if (!payload.sessionId) { - throw new UnauthorizedException({ - error: 'MISSING_SESSION_ID', - message: 'Token does not contain session id', - }); - } - - try { - await this.authSessionService.validateSession(payload.sessionId); - } catch (error) { - if (error instanceof UnauthorizedException) { - throw error; - } - throw new UnauthorizedException({ - error: 'INVALID_SESSION', - message: 'Session is expired, revoked, or inactive', - }); - } - - (request as Request & { user: JwtPayload }).user = payload; - return true; - } - - private extractBearerToken(request: Request): string | undefined { - const [type, token] = request.headers.authorization?.split(' ') ?? []; - return type === 'Bearer' ? token : undefined; - } -} diff --git a/BackendAcademy/src/auth/guards/jwt-tutor.guard.ts b/BackendAcademy/src/auth/guards/jwt-tutor.guard.ts deleted file mode 100644 index 3e1c5ab81c..0000000000 --- a/BackendAcademy/src/auth/guards/jwt-tutor.guard.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { - CanActivate, - ExecutionContext, - Injectable, - UnauthorizedException, - ForbiddenException, -} from '@nestjs/common'; -import { JwtService } from '@nestjs/jwt'; -import { Request } from 'express'; -import { JwtPayload } from '../interfaces/jwt-payload.interface'; -import { UserRole } from '../enums/user-role.enum'; -import { AuthSessionService } from '../auth-session.service'; - -/** - * Protects routes that require a valid tutor JWT. - * - * Expects an `Authorization: Bearer ` Header. - * The token payload must contain `role: "tutor"` and a `sessionId`. - * The associated session must not be expired, revoked, or idle. - * - * On success, attaches `request.tutor` with the decoded payload. - */ -@Injectable() -export class JwtTutorGuard implements CanActivate { - constructor( - private readonly jwtService: JwtService, - private readonly authSessionService: AuthSessionService, - ) {} - - async canActivate(context: ExecutionContext): Promise { - const request = context.switchToHttp().getRequest(); - const token = this.extractBearerToken(request); - - if (!token) { - throw new UnauthorizedException({ - error: 'MISSING_TOKEN', - message: 'Authorization header with Bearer token is required', - }); - } - - let payload: JwtPayload; - try { - payload = await this.jwtService.verifyAsync(token); - } catch { - throw new UnauthorizedException({ - error: 'INVALID_TOKEN', - message: 'Token is invalid or has expired', - }); - } - - if (payload.role !== UserRole.TUTOR) { - throw new ForbiddenException({ - error: 'TUTIOR_ROLE_REQUIRED', - message: 'Only tutors are allowed to access this resource', - }); - } - - // Enforce session expiration independently of JWT verification. - // The JWT may be valid, but the session could be expired, revoked, - // or idle beyond the allowed timeout. Session validation also - // refreshes the last activity timestamp and removes/marks expired - // sessions as required. - if (!payload.sessionId) { - throw new UnauthorizedException({ - error: 'MISSING_SESSION', - message: 'Token does not contain a session identifier', - }); - } - - try { - await this.authSessionService.validateAndRefreshSession(payload.sessionId); - } catch (error) { - if (error instanceof UnauthorizedException) { - throw error; - } - throw new UnauthorizedException({ - error: 'INVALID_SESSION', - message: 'Session is expired, revoked, or inactive', - }); - } - - // Attach decoded tutor identity for downstream handlers - (request as Request & { tutor: JwtPayload }).tutor = payload; - return true; - } - - private extractBearerToken(request: Request): string | undefined { - const [type, token] = request.headers.authorization?.split(' ') ?? []; - return type === 'Bearer' ? token : undefined; - } -} diff --git a/BackendAcademy/src/auth/guards/roles.guard.ts b/BackendAcademy/src/auth/guards/roles.guard.ts deleted file mode 100644 index cda748954a..0000000000 --- a/BackendAcademy/src/auth/guards/roles.guard.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { - CanActivate, - ExecutionContext, - ForbiddenException, - Injectable, -} from '@nestjs/common'; -import { Reflector } from '@nestjs/core'; -import { UserRole } from '../enums/user-role.enum'; -import { ROLES_KEY } from '../decorators/roles.decorator'; -import { JwtPayload } from '../interfaces/jwt-payload.interface'; - -/** - * Guard that checks if the authenticated user has the required role(s). - * - * This guard should be used in combination with a JWT authentication guard - * (e.g., JwtLearnerGuard, JwtTutorGuard, JwtAdminGuard) and the @Roles() decorator. - * - * Usage: - * @UseGuards(JwtAuthGuard, RolesGuard) - * @Roles(UserRole.ADMIN, UserRole.TUTOR) - * async someMethod() { ... } - */ -@Injectable() -export class RolesGuard implements CanActivate { - constructor(private readonly reflector: Reflector) {} - - canActivate(context: ExecutionContext): boolean { - const requiredRoles = this.reflector.getAllAndOverride( - ROLES_KEY, - [context.getHandler(), context.getClass()], - ); - - // If no roles are required, allow access - if (!requiredRoles || requiredRoles.length === 0) { - return true; - } - - const request = context.switchToHttp().getRequest(); - const user: JwtPayload = request.user; - - if (!user) { - throw new ForbiddenException({ - error: 'NO_USER', - message: 'User not authenticated', - }); - } - - const hasRole = requiredRoles.includes(user.role); - - if (!hasRole) { - throw new ForbiddenException({ - error: 'INSUFFICIENT_PERMISSIONS', - message: `User does not have required role(s): ${requiredRoles.join(', ')}`, - }); - } - - return true; - } -} diff --git a/BackendAcademy/src/auth/index.ts b/BackendAcademy/src/auth/index.ts deleted file mode 100644 index 7037017e41..0000000000 --- a/BackendAcademy/src/auth/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -export { AuthModule } from './auth.module'; -export { JwtLearnerGuard } from './guards/jwt-learner.guard'; -export { JwtTutorGuard } from './guards/jwt-tutor.guard'; -export { JwtAdminGuard } from './guards/jwt-admin.guard'; -export { RolesGuard } from './guards/roles.guard'; -export { Roles, ROLES_KEY } from './decorators/roles.decorator'; -export { UserRole } from './enums/user-role.enum'; -export { JwtPayload } from './interfaces/jwt-payload.interface'; -export { AuthSessionService } from './auth-session.service'; -export { AuthSessionController } from './auth-session.controller'; -export { LoginDto } from './dto/login.dto'; -export { RefreshTokenDto } from './dto/refresh-token.dto'; -export type { - Session, - RefreshTokenPayload, - AuthTokensResponse, -} from './interfaces/session.interface'; diff --git a/BackendAcademy/src/auth/interfaces/jwt-payload.interface.ts b/BackendAcademy/src/auth/interfaces/jwt-payload.interface.ts deleted file mode 100644 index ab749a3ac3..0000000000 --- a/BackendAcademy/src/auth/interfaces/jwt-payload.interface.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { UserRole } from '../enums/user-role.enum'; - -export interface JwtPayload { - sub: string; - role: UserRole; - sessionId: string; - type: 'access' | 'refresh'; - iat?: number; - exp?: number; -} diff --git a/BackendAcademy/src/auth/interfaces/session.interface.ts b/BackendAcademy/src/auth/interfaces/session.interface.ts deleted file mode 100644 index ef7af6054a..0000000000 --- a/BackendAcademy/src/auth/interfaces/session.interface.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { UserRole } from '../enums/user-role.enum'; - -export interface RefreshTokenPayload { - sub: string; - role: UserRole; - sessionId: string; - type: 'refresh'; -} - -export interface AuthTokensResponse { - accessToken: string; - refreshToken: string; - tokenType: string; - expiresIn: number; -} - -export interface Session { - sessionId: string; - userId: string; - role: UserRole; - refreshTokenHash: string; - createdAt: Date; - expiresAt: Date; - absoluteExpiresAt: Date; - idleExpiresAt: Date; - deliveryGraceSeconds: number; - lastActivityAt: Date; - revoked: boolean; - revokedAt?: Date; - deviceHash?: string; - isTrustedDevice?: boolean; -} diff --git a/BackendAcademy/src/auth/jwt-clock-skew.spec.ts b/BackendAcademy/src/auth/jwt-clock-skew.spec.ts deleted file mode 100644 index ec9ca174a2..0000000000 --- a/BackendAcademy/src/auth/jwt-clock-skew.spec.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { ForbiddenException, UnauthorizedException } from '@nestjs/common'; -import { JwtService } from '@nestjs/jwt'; -import { Request } from 'express'; -import { JwtAdminGuard } from './guards/jwt-admin.guard'; -import { AuthSessionService } from './auth-session.service'; -import { UserRole } from './enums/user-role.enum'; -import { JwtPayload } from './interfaces/jwt-payload.interface'; - -function buildMockAuthSessionService() { - return { - validateSession: jest.fn().mockResolvedValue(undefined), - } as unknown as AuthSessionService; -} - -/** - * BA-023 — Token clock-skew policy. - * - * Distributed clocks can drift, causing a verifier whose clock is slightly - * ahead of the signer's to reject a freshly-issued token ("premature expiry") - * or a verifier whose clock is behind to accept a genuinely expired token. - * - * The policy under test: - * - The allowed clock skew is EXPLICIT (a named config value) and BOUNDED - * (rejected by config validation if widened irresponsibly). - * - Verification applies that same bounded tolerance via `clockTolerance`, - * so a token that has only just expired is still accepted, while one that - * expired far beyond the tolerance is rejected. - * - * These tests use Jest fake timers to deterministically control the verifier's - * clock relative to the signer's. - */ - -const SECRET = 'test-secret'; -const BOUNDED_SKEW_SECONDS = 30; -const HARD_MAX_SKEW_SECONDS = 120; - -/** Build a JwtService configured exactly like the production AuthModule factory. */ -function buildJwtService(skewSeconds: number): JwtService { - return new JwtService({ - secret: SECRET, - signOptions: { expiresIn: '7d' }, - verifyOptions: { clockTolerance: skewSeconds }, - }); -} - -function nowInSeconds(): number { - return Math.floor(Date.now() / 1000); -} - -function makeContextWithToken(token: string) { - const request = { - headers: { authorization: `Bearer ${token}` }, - } as unknown as Request; - const context = { - switchToHttp: () => ({ - getRequest: () => request, - }), - } as any; - return { context, request }; -} - -describe('JWT clock skew policy (BA-023)', () => { - let jwtService: JwtService; - - beforeEach(() => { - // Anchor the signer's clock at a fixed instant. - jest.useFakeTimers(); - jest.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); - jwtService = buildJwtService(BOUNDED_SKEW_SECONDS); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - describe('verification honors the bounded clock tolerance', () => { - it('accepts a token whose expiry has only just passed (verifier clock ahead, within tolerance)', async () => { - // Signer issues token with 7d lifetime ending at 2026-01-08T00:00:00Z. - const token = jwtService.sign({ sub: 'u1', role: UserRole.ADMIN }); - - // Verifier's clock drifts 10s past the token's expiry — still within the - // 30s tolerance, so the just-expired token must NOT be rejected. - jest.setSystemTime(new Date('2026-01-08T00:00:10.000Z')); - - await expect(jwtService.verifyAsync(token)).resolves.toMatchObject({ - sub: 'u1', - role: UserRole.ADMIN, - }); - }); - - it('accepts a token whose not-before claim is slightly in the future (verifier clock ahead of signer)', async () => { - // nbf is in the near future relative to the verifier; within tolerance. - const token = jwtService.sign({ - sub: 'u1', - role: UserRole.ADMIN, - nbf: nowInSeconds() + 10, - }); - - await expect(jwtService.verifyAsync(token)).resolves.toMatchObject({ - sub: 'u1', - }); - }); - - it('rejects a token expired beyond the tolerance', async () => { - const token = jwtService.sign({ sub: 'u1', role: UserRole.ADMIN }); - - // 60s past expiry — well beyond the 30s tolerance. - jest.setSystemTime(new Date('2026-01-08T00:01:00.000Z')); - - await expect(jwtService.verifyAsync(token)).rejects.toThrow(); - }); - - it('rejects a token in the future beyond the tolerance (nbf too far ahead)', async () => { - const token = jwtService.sign({ - sub: 'u1', - role: UserRole.TUTOR, - nbf: nowInSeconds() + 120, - }); - - await expect(jwtService.verifyAsync(token)).rejects.toThrow(); - }); - }); - - describe('guards apply the same policy end-to-end', () => { - it('allows an admin token that has just expired within the tolerance', async () => { - const token = jwtService.sign({ sub: 'u1', role: UserRole.ADMIN, sessionId: 'test-session' }); - jest.setSystemTime(new Date('2026-01-08T00:00:20.000Z')); - - const guard = new JwtAdminGuard(jwtService, buildMockAuthSessionService()); - const { context, request } = makeContextWithToken(token); - - await expect(guard.canActivate(context)).resolves.toBe(true); - expect((request as Request & { user: JwtPayload }).user.sub).toBe('u1'); - }); - - it('rejects an admin token that expired beyond the tolerance', async () => { - const token = jwtService.sign({ sub: 'u1', role: UserRole.ADMIN, sessionId: 'test-session' }); - jest.setSystemTime(new Date('2026-01-08T00:01:30.000Z')); - - const guard = new JwtAdminGuard(jwtService, buildMockAuthSessionService()); - const { context } = makeContextWithToken(token); - - await expect(guard.canActivate(context)).rejects.toBeInstanceOf( - UnauthorizedException, - ); - }); - - it('still enforces role checks within the allowed skew', async () => { - const token = jwtService.sign({ sub: 'u1', role: UserRole.LEARNER, sessionId: 'test-session' }); - jest.setSystemTime(new Date('2026-01-08T00:00:20.000Z')); - - const guard = new JwtAdminGuard(jwtService, buildMockAuthSessionService()); - const { context } = makeContextWithToken(token); - - await expect(guard.canActivate(context)).rejects.toBeInstanceOf( - ForbiddenException, - ); - }); - }); - - describe('the allowed skew is explicit and bounded', () => { - it('exposes a single named, positive upper-bounded value', () => { - expect(BOUNDED_SKEW_SECONDS).toBeGreaterThan(0); - expect(BOUNDED_SKEW_SECONDS).toBeLessThanOrEqual(HARD_MAX_SKEW_SECONDS); - }); - - it('tolerance above the hard maximum cannot reach verification', () => { - // Any value above the hard max must be rejected up-front by config - // validation; verification itself never receives an unbounded value. - expect(HARD_MAX_SKEW_SECONDS).toBeLessThan(3600); - expect(() => buildJwtService(HARD_MAX_SKEW_SECONDS)).not.toThrow(); - expect(BOUNDED_SKEW_SECONDS).toBeLessThanOrEqual(HARD_MAX_SKEW_SECONDS); - }); - - it('lets a caller widen the tolerance within the bound for legitimate use', async () => { - const generous = buildJwtService(HARD_MAX_SKEW_SECONDS); - const token = generous.sign({ sub: 'u1', role: UserRole.ADMIN }); - - // 90s past expiry — beyond the default 30s but comfortably inside the - // widened 120s tolerance. - jest.setSystemTime(new Date('2026-01-08T00:01:30.000Z')); - - await expect(generous.verifyAsync(token)).resolves.toMatchObject({ - sub: 'u1', - }); - }); - }); -}); diff --git a/BackendAcademy/src/badges/badges.controller.ts b/BackendAcademy/src/badges/badges.controller.ts deleted file mode 100644 index a1bc1aed3b..0000000000 --- a/BackendAcademy/src/badges/badges.controller.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Controller, Get, Post, Body, Param, HttpCode, HttpStatus } from '@nestjs/common'; -import { BadgesService } from './badges.service'; -import type { BadgeListResponse, UserBadgesResponse } from './interfaces/badges.interfaces'; - -@Controller('badges') -export class BadgesController { - constructor(private readonly badgesService: BadgesService) {} - - /** - * Returns all available achievement badges. - * - * @example - * GET /badges - * → { badges: [{ id: "first-login", name: "First Steps", ... }, ...] } - */ - @Get() - @HttpCode(HttpStatus.OK) - getAllBadges(): BadgeListResponse { - return this.badgesService.getAllBadges(); - } - - /** - * Returns all badges awarded to a specific user. - * - * @param userId The ID of the user - * - * @example - * GET /badges/user/user-123 - * → { userId: "user-123", badges: [{ badge: {...}, awardedAt: "...", nftTokenId: "..." }, ...] } - */ - @Get('user/:userId') - @HttpCode(HttpStatus.OK) - getUserBadges(@Param('userId') userId: string): UserBadgesResponse { - return this.badgesService.getUserBadges(userId); - } - - /** - * Awards a badge to a user. - * (Internal endpoint/Admin endpoint in a real system) - * - * @param awardPayload The payload containing userId, badgeId, and nftTokenId - * - * @example - * POST /badges/award - * → { userId: "user-123", badges: [...] } - */ - @Post('award') - @HttpCode(HttpStatus.OK) - awardBadge( - @Body() - awardPayload: { userId: string; badgeId: string; nftTokenId: string }, - ): UserBadgesResponse { - return this.badgesService.awardBadge( - awardPayload.userId, - awardPayload.badgeId, - awardPayload.nftTokenId, - ); - } -} diff --git a/BackendAcademy/src/badges/badges.module.ts b/BackendAcademy/src/badges/badges.module.ts deleted file mode 100644 index 911fb2de31..0000000000 --- a/BackendAcademy/src/badges/badges.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from '@nestjs/common'; -import { BadgesController } from './badges.controller'; -import { BadgesService } from './badges.service'; - -@Module({ - controllers: [BadgesController], - providers: [BadgesService], - exports: [BadgesService], -}) -export class BadgesModule {} diff --git a/BackendAcademy/src/badges/badges.service.spec.ts b/BackendAcademy/src/badges/badges.service.spec.ts deleted file mode 100644 index eccd0b2cb1..0000000000 --- a/BackendAcademy/src/badges/badges.service.spec.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { BadgesService } from './badges.service'; -import { NotFoundException } from '@nestjs/common'; - -describe('BadgesService', () => { - let service: BadgesService; - const USER_ID = 'test-user-123'; - const BADGE_ID = 'first-login'; - const NFT_TOKEN_ID = '0xabc123'; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [BadgesService], - }).compile(); - - service = module.get(BadgesService); - }); - - describe('getAllBadges', () => { - it('should return all defined badges', () => { - const { badges } = service.getAllBadges(); - expect(badges.length).toBeGreaterThan(0); - expect(badges[0]).toHaveProperty('id'); - expect(badges[0]).toHaveProperty('name'); - }); - }); - - describe('getUserBadges', () => { - it('should return an empty list for a user with no badges', () => { - const { badges } = service.getUserBadges(USER_ID); - expect(badges).toEqual([]); - }); - - it('should return the correct user id', () => { - const { userId } = service.getUserBadges(USER_ID); - expect(userId).toBe(USER_ID); - }); - }); - - describe('awardBadge', () => { - it('should award a badge to a user', () => { - const response = service.awardBadge(USER_ID, BADGE_ID, NFT_TOKEN_ID); - expect(response.userId).toBe(USER_ID); - expect(response.badges.length).toBe(1); - expect(response.badges[0].badge.id).toBe(BADGE_ID); - expect(response.badges[0].nftTokenId).toBe(NFT_TOKEN_ID); - }); - - it('should not award the same badge twice to the same user', () => { - service.awardBadge(USER_ID, BADGE_ID, NFT_TOKEN_ID); - const response = service.awardBadge(USER_ID, BADGE_ID, 'different-token'); - expect(response.badges.length).toBe(1); - }); - - it('should throw NotFoundException for non-existent badge', () => { - expect(() => service.awardBadge(USER_ID, 'non-existent', NFT_TOKEN_ID)).toThrow( - NotFoundException, - ); - }); - }); - - describe('resetUserBadges', () => { - it('should clear all badges for a user', () => { - service.awardBadge(USER_ID, BADGE_ID, NFT_TOKEN_ID); - service.resetUserBadges(USER_ID); - const { badges } = service.getUserBadges(USER_ID); - expect(badges.length).toBe(0); - }); - }); -}); diff --git a/BackendAcademy/src/badges/badges.service.ts b/BackendAcademy/src/badges/badges.service.ts deleted file mode 100644 index 66c90602d1..0000000000 --- a/BackendAcademy/src/badges/badges.service.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; -import type { - Badge, - BadgeListResponse, - UserBadge, - UserBadgesResponse, -} from './interfaces/badges.interfaces'; - -/** - * In-memory store for badge definitions. - */ -const badgeDefinitions: Record = { - 'first-login': { - id: 'first-login', - name: 'First Steps', - description: 'Log in for the first time.', - iconUrl: 'https://api.dicebear.com/7.x/identicon/svg?seed=first-login', - }, - 'ten-submissions': { - id: 'ten-submissions', - name: 'Dedicated Learner', - description: 'Complete 10 course submissions.', - iconUrl: 'https://api.dicebear.com/7.x/identicon/svg?seed=ten-submissions', - }, - 'streak-seven': { - id: 'streak-seven', - name: 'Week Warrior', - description: 'Maintain a 7-day activity streak.', - iconUrl: 'https://api.dicebear.com/7.x/identicon/svg?seed=streak-seven', - }, -}; - -/** - * In-memory store for user-awarded badges. - * Keyed by userId -> Array of UserBadge - */ -const userBadgesStore = new Map(); - -@Injectable() -export class BadgesService { - /** - * Returns all available achievement badges. - */ - getAllBadges(): BadgeListResponse { - return { - badges: Object.values(badgeDefinitions), - }; - } - - /** - * Returns all badges awarded to a specific user. - * - * @throws NotFoundException if the userId is unknown - */ - getUserBadges(userId: string): UserBadgesResponse { - // In a real app, we would check if user exists in the User service. - // For this in-memory implementation, we'll assume any userId is valid - // but return an empty list if they have no badges. - const badges = userBadgesStore.get(userId) ?? []; - return { - userId, - badges, - }; - } - - /** - * Awards a badge to a user. - * - * @param userId Target user - * @param badgeId ID of the badge to award - * @param nftTokenId The NFT token ID for this award - * @returns The updated list of user badges - * - * @throws NotFoundException if the badgeId does not exist - */ - awardBadge( - userId: string, - badgeId: string, - nftTokenId: string, - ): UserBadgesResponse { - const badge = badgeDefinitions[badgeId]; - if (!badge) { - throw new NotFoundException(`Badge '${badgeId}' not found.`); - } - - const currentBadges = userBadgesStore.get(userId) ?? []; - - // Prevent duplicate awards - if (currentBadges.some((ub) => ub.badge.id === badgeId)) { - return this.getUserBadges(userId); - } - - const newUserBadge: UserBadge = { - badge, - awardedAt: new Date().toISOString(), - nftTokenId, - }; - - userBadgesStore.set(userId, [...currentBadges, newUserBadge]); - - return this.getUserBadges(userId); - } - - /** - * Resets a user's badges (useful for testing). - */ - resetUserBadges(userId: string): void { - userBadgesStore.delete(userId); - } - - /** - * Clears all in-memory badge data (useful for testing isolation). - */ - clearAll(): void { - userBadgesStore.clear(); - } -} diff --git a/BackendAcademy/src/badges/interfaces/badges.interfaces.ts b/BackendAcademy/src/badges/interfaces/badges.interfaces.ts deleted file mode 100644 index 1a0ea09114..0000000000 --- a/BackendAcademy/src/badges/interfaces/badges.interfaces.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * Definition of an achievement badge. - */ -export interface Badge { - /** Unique identifier for the badge */ - id: string; - /** Human-readable name of the badge */ - name: string; - /** Description of how to earn the badge */ - description: string; - /** URL to the badge icon image */ - iconUrl: string; -} - -/** - * An instance of a badge awarded to a specific user. - */ -export interface UserBadge { - /** The badge definition */ - badge: Badge; - /** The date and time the badge was awarded (ISO 8601) */ - awardedAt: string; - /** The unique token ID of the badge NFT on the blockchain */ - nftTokenId: string; -} - -/** - * Response shape for GET /badges/user/:userId - */ -export interface UserBadgesResponse { - /** The ID of the user */ - userId: string; - /** The list of badges awarded to the user */ - badges: UserBadge[]; -} - -/** - * Response shape for GET /badges - */ -export interface BadgeListResponse { - /** The list of all available achievement badges */ - badges: Badge[]; -} diff --git a/BackendAcademy/src/badges/repositories/badges.repository.in-memory.ts b/BackendAcademy/src/badges/repositories/badges.repository.in-memory.ts deleted file mode 100644 index 80db0ecd3a..0000000000 --- a/BackendAcademy/src/badges/repositories/badges.repository.in-memory.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Badge, UserBadge } from '../interfaces/badges.interfaces'; -import { IBadgesRepository } from './badges.repository.interface'; - -/** - * In-memory implementation of the badges repository. - * Stores badge definitions and user-awarded badges in process-local Maps. - */ -export class InMemoryBadgesRepository implements IBadgesRepository { - private readonly badgeDefinitions: Record = { - 'first-login': { - id: 'first-login', - name: 'First Steps', - description: 'Log in for the first time.', - iconUrl: 'https://api.dicebear.com/7.x/identicon/svg?seed=first-login', - }, - 'ten-submissions': { - id: 'ten-submissions', - name: 'Dedicated Learner', - description: 'Complete 10 course submissions.', - iconUrl: 'https://api.dicebear.com/7.x/identicon/svg?seed=ten-submissions', - }, - 'streak-seven': { - id: 'streak-seven', - name: 'Week Warrior', - description: 'Maintain a 7-day activity streak.', - iconUrl: 'https://api.dicebear.com/7.x/identicon/svg?seed=streak-seven', - }, - }; - - private readonly userBadgesStore = new Map(); - - getAllBadges(): Badge[] { - return Object.values(this.badgeDefinitions); - } - - getBadgeById(badgeId: string): Badge | undefined { - return this.badgeDefinitions[badgeId]; - } - - getUserBadges(userId: string): UserBadge[] { - return this.userBadgesStore.get(userId) ?? []; - } - - awardBadge(userId: string, userBadge: UserBadge): void { - const currentBadges = this.userBadgesStore.get(userId) ?? []; - this.userBadgesStore.set(userId, [...currentBadges, userBadge]); - } - - resetUserBadges(userId: string): void { - this.userBadgesStore.delete(userId); - } - - clearAll(): void { - this.userBadgesStore.clear(); - } -} diff --git a/BackendAcademy/src/badges/repositories/index.ts b/BackendAcademy/src/badges/repositories/index.ts deleted file mode 100644 index 2efb498a0f..0000000000 --- a/BackendAcademy/src/badges/repositories/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { IBadgesRepository } from './badges.repository.interface'; -export { InMemoryBadgesRepository } from './badges.repository.in-memory'; diff --git a/BackendAcademy/src/challenges/challenges.controller.ts b/BackendAcademy/src/challenges/challenges.controller.ts deleted file mode 100644 index 44babd6704..0000000000 --- a/BackendAcademy/src/challenges/challenges.controller.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { - Body, - Controller, - Get, - HttpCode, - HttpStatus, - Param, - Post, - Put, -} from '@nestjs/common'; -import { ChallengesService, AttemptInfo } from './challenges.service'; -import { CastChallengeVoteDto } from './dto/cast-challenge-vote.dto'; -import { - ChallengeVoteResponse, - ChallengeVoteTally, -} from './interfaces/challenge-vote.interface'; - -@Controller('challenges') -export class ChallengesController { - constructor(private readonly challengesService: ChallengesService) {} - - // --------------------------------------------------------------------------- - // Voting - // --------------------------------------------------------------------------- - - @Post(':challengeId/votes') - @HttpCode(HttpStatus.OK) - castVote( - @Param('challengeId') challengeId: string, - @Body() dto: CastChallengeVoteDto, - ): ChallengeVoteResponse { - return this.challengesService.castVote(challengeId, dto); - } - - @Get(':challengeId/votes') - getTally(@Param('challengeId') challengeId: string): ChallengeVoteTally { - return this.challengesService.getTally(challengeId); - } - - // --------------------------------------------------------------------------- - // Attempt-aware challenge submission - // --------------------------------------------------------------------------- - - /** - * POST /challenges/:challengeId/submit - * - * Record an attempt for the learner on the given challenge. Before recording - * the attempt the service verifies the learner has not exhausted their - * allowed attempts. - * - * Returns the updated attempt count (1-based) for confirmation. - * - * Body: - * userId (string, required) – ID of the submitting learner. - */ - @Post(':challengeId/submit') - @HttpCode(HttpStatus.OK) - submitAttempt( - @Param('challengeId') challengeId: string, - @Body('userId') userId: string, - ): { challengeId: string; userId: string; attemptNumber: number } { - const attemptNumber = this.challengesService.recordAttempt( - challengeId, - userId, - ); - - return { - challengeId, - userId, - attemptNumber, - }; - } - - /** - * GET /challenges/:challengeId/attempts/:userId - * - * Returns the current attempt usage for a learner on a specific challenge. - */ - @Get(':challengeId/attempts/:userId') - getAttemptInfo( - @Param('challengeId') challengeId: string, - @Param('userId') userId: string, - ): AttemptInfo { - return this.challengesService.getAttemptInfo(challengeId, userId); - } - - /** - * PUT /challenges/:challengeId/attempts/limit - * - * (Admin) Override the maximum number of attempts allowed for a challenge. - * - * Body: - * maxAttempts (number, required) – Positive integer. - */ - @Put(':challengeId/attempts/limit') - @HttpCode(HttpStatus.OK) - setMaxAttempts( - @Param('challengeId') challengeId: string, - @Body('maxAttempts') maxAttempts: number, - ): { challengeId: string; maxAttempts: number } { - this.challengesService.setMaxAttempts(challengeId, maxAttempts); - return { challengeId, maxAttempts }; - } -} diff --git a/BackendAcademy/src/challenges/challenges.module.spec.ts b/BackendAcademy/src/challenges/challenges.module.spec.ts deleted file mode 100644 index 65270779ed..0000000000 --- a/BackendAcademy/src/challenges/challenges.module.spec.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Test } from '@nestjs/testing'; -import { ChallengesController } from './challenges.controller'; -import { ChallengesModule } from './challenges.module'; -import { ChallengesService } from './challenges.service'; - -describe('ChallengesModule', () => { - it('registers challenge voting controller and service', async () => { - const moduleRef = await Test.createTestingModule({ - imports: [ChallengesModule], - }).compile(); - - expect(moduleRef.get(ChallengesController)).toBeInstanceOf(ChallengesController); - expect(moduleRef.get(ChallengesService)).toBeInstanceOf(ChallengesService); - }); -}); diff --git a/BackendAcademy/src/challenges/challenges.module.ts b/BackendAcademy/src/challenges/challenges.module.ts deleted file mode 100644 index 9525121f71..0000000000 --- a/BackendAcademy/src/challenges/challenges.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ChallengesController } from './challenges.controller'; -import { ChallengesService } from './challenges.service'; - -@Module({ - controllers: [ChallengesController], - providers: [ChallengesService], - exports: [ChallengesService], -}) -export class ChallengesModule {} diff --git a/BackendAcademy/src/challenges/challenges.service.spec.ts b/BackendAcademy/src/challenges/challenges.service.spec.ts deleted file mode 100644 index be9474066f..0000000000 --- a/BackendAcademy/src/challenges/challenges.service.spec.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { BadRequestException } from '@nestjs/common'; -import { ChallengesService } from './challenges.service'; - -describe('ChallengesService', () => { - let service: ChallengesService; - - beforeEach(() => { - service = new ChallengesService(); - }); - - // --------------------------------------------------------------------------- - // Vote tracking (existing) - // --------------------------------------------------------------------------- - - describe('vote tracking', () => { - it('records an upvote and returns the tally', () => { - const result = service.castVote('weekly-rust-1', { - userId: 'learner-1', - value: 'up', - }); - - expect(result).toEqual({ - challengeId: 'weekly-rust-1', - downvotes: 0, - score: 1, - totalVotes: 1, - upvotes: 1, - userId: 'learner-1', - userVote: 'up', - }); - }); - - it('updates a repeated user vote instead of double counting', () => { - service.castVote('weekly-rust-1', { userId: 'learner-1', value: 'up' }); - service.castVote('weekly-rust-1', { userId: 'learner-1', value: 'down' }); - service.castVote('weekly-rust-1', { userId: 'learner-2', value: 'up' }); - - expect(service.getTally('weekly-rust-1')).toEqual({ - challengeId: 'weekly-rust-1', - downvotes: 1, - score: 0, - totalVotes: 2, - upvotes: 1, - }); - }); - - it('keeps challenge tallies isolated', () => { - service.castVote('challenge-a', { userId: 'learner-1', value: 'up' }); - service.castVote('challenge-b', { userId: 'learner-1', value: 'down' }); - - expect(service.getTally('challenge-a').score).toBe(1); - expect(service.getTally('challenge-b').score).toBe(-1); - }); - - it('rejects invalid vote values', () => { - expect(() => - service.castVote('challenge-a', { - userId: 'learner-1', - value: 'maybe' as 'up', - }), - ).toThrow(BadRequestException); - }); - }); - - // --------------------------------------------------------------------------- - // Attempt limit tracking (new) - // --------------------------------------------------------------------------- - - describe('attempt limit tracking', () => { - it('returns allowed=true and correct default remaining for first attempt', () => { - const info = service.getAttemptInfo('weekly-rust-2', 'learner-42'); - - expect(info).toEqual({ - challengeId: 'weekly-rust-2', - userId: 'learner-42', - current: 0, - max: 3, - remaining: 3, - allowed: true, - }); - }); - - it('returns remaining=0 after exhausting all attempts', () => { - service.recordAttempt('challenge-x', 'user-1'); - service.recordAttempt('challenge-x', 'user-1'); - service.recordAttempt('challenge-x', 'user-1'); - - const info = service.getAttemptInfo('challenge-x', 'user-1'); - - expect(info.current).toBe(3); - expect(info.remaining).toBe(0); - expect(info.allowed).toBe(false); - }); - - it('throws BadRequestException when attempt limit is exceeded', () => { - service.recordAttempt('challenge-x', 'user-1'); - service.recordAttempt('challenge-x', 'user-1'); - service.recordAttempt('challenge-x', 'user-1'); - - expect(() => service.recordAttempt('challenge-x', 'user-1')).toThrow( - BadRequestException, - ); - }); - - it('uses a custom max when configured via setMaxAttempts', () => { - service.setMaxAttempts('hard-challenge', 1); - - service.recordAttempt('hard-challenge', 'user-1'); - expect(() => service.recordAttempt('hard-challenge', 'user-1')).toThrow( - BadRequestException, - ); - }); - - it('keeps attempt counters isolated per user', () => { - service.recordAttempt('challenge-y', 'alice'); - service.recordAttempt('challenge-y', 'alice'); - service.recordAttempt('challenge-y', 'bob'); - - const alice = service.getAttemptInfo('challenge-y', 'alice'); - const bob = service.getAttemptInfo('challenge-y', 'bob'); - - expect(alice.current).toBe(2); - expect(bob.current).toBe(1); - }); - - it('keeps attempt counters isolated per challenge', () => { - service.recordAttempt('challenge-a', 'user-1'); - service.recordAttempt('challenge-b', 'user-1'); - - expect(service.getAttemptInfo('challenge-a', 'user-1').current).toBe(1); - expect(service.getAttemptInfo('challenge-b', 'user-1').current).toBe(1); - }); - - it('rejects setMaxAttempts with a non-positive value', () => { - expect(() => service.setMaxAttempts('ch', 0)).toThrow(BadRequestException); - expect(() => service.setMaxAttempts('ch', -1)).toThrow(BadRequestException); - expect(() => service.setMaxAttempts('ch', 3.5)).toThrow(BadRequestException); - }); - - it('resets all attempt counters via resetAttempts', () => { - service.recordAttempt('challenge-a', 'u1'); - service.recordAttempt('challenge-a', 'u1'); - service.resetAttempts(); - - expect(service.getAttemptInfo('challenge-a', 'u1').current).toBe(0); - }); - - it('resets attempt counters for a single challenge via resetAttemptsForChallenge', () => { - service.recordAttempt('challenge-a', 'u1'); - service.recordAttempt('challenge-b', 'u1'); - service.resetAttemptsForChallenge('challenge-a'); - - expect(service.getAttemptInfo('challenge-a', 'u1').current).toBe(0); - expect(service.getAttemptInfo('challenge-b', 'u1').current).toBe(1); - }); - - it('records the attempt and returns the incremented count', () => { - const count1 = service.recordAttempt('ch', 'user'); - expect(count1).toBe(1); - - const count2 = service.recordAttempt('ch', 'user'); - expect(count2).toBe(2); - }); - - it('throws on recordAttempt with empty userId', () => { - expect(() => service.recordAttempt('ch', '')).toThrow(BadRequestException); - }); - - it('throws on recordAttempt with empty challengeId', () => { - expect(() => service.recordAttempt('', 'user')).toThrow(BadRequestException); - }); - }); -}); diff --git a/BackendAcademy/src/challenges/challenges.service.ts b/BackendAcademy/src/challenges/challenges.service.ts deleted file mode 100644 index 3f380e6fe8..0000000000 --- a/BackendAcademy/src/challenges/challenges.service.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; -import { CastChallengeVoteDto } from './dto/cast-challenge-vote.dto'; -import { - ChallengeVoteResponse, - ChallengeVoteTally, - ChallengeVoteValue, -} from './interfaces/challenge-vote.interface'; - -export interface AttemptInfo { - challengeId: string; - userId: string; - current: number; - max: number; - remaining: number; - allowed: boolean; -} - -/** Default maximum attempts per challenge when no explicit limit is configured. */ -const DEFAULT_MAX_ATTEMPTS = 3; - -@Injectable() -export class ChallengesService { - private readonly votesByChallenge = new Map>(); - - /** Per-challenge attempt limit overrides. Falls back to DEFAULT_MAX_ATTEMPTS. */ - private readonly maxAttempts = new Map(); - - /** Per-challenge per-user attempt counters: challengeId -> userId -> count. */ - private readonly attemptCounts = new Map>(); - - // --------------------------------------------------------------------------- - // Vote tracking - // --------------------------------------------------------------------------- - - castVote(challengeId: string, dto: CastChallengeVoteDto): ChallengeVoteResponse { - const normalizedChallengeId = this.normalizeId(challengeId, 'challengeId'); - const userId = this.normalizeId(dto.userId, 'userId'); - const value = this.normalizeVote(dto.value); - - let votes = this.votesByChallenge.get(normalizedChallengeId); - if (!votes) { - votes = new Map(); - this.votesByChallenge.set(normalizedChallengeId, votes); - } - - votes.set(userId, value); - return { - ...this.getTally(normalizedChallengeId), - userId, - userVote: value, - }; - } - - getTally(challengeId: string): ChallengeVoteTally { - const normalizedChallengeId = this.normalizeId(challengeId, 'challengeId'); - const votes = this.votesByChallenge.get(normalizedChallengeId); - let upvotes = 0; - let downvotes = 0; - - for (const value of votes?.values() ?? []) { - if (value === 'up') { - upvotes += 1; - } else { - downvotes += 1; - } - } - - return { - challengeId: normalizedChallengeId, - downvotes, - score: upvotes - downvotes, - totalVotes: upvotes + downvotes, - upvotes, - }; - } - - resetVotes(): void { - this.votesByChallenge.clear(); - } - - // --------------------------------------------------------------------------- - // Attempt limit management - // --------------------------------------------------------------------------- - - /** - * Configure the maximum number of attempts allowed for a given challenge. - * Must be a positive integer. - */ - setMaxAttempts(challengeId: string, max: number): void { - const normalizedId = this.normalizeId(challengeId, 'challengeId'); - if (!Number.isInteger(max) || max < 1) { - throw new BadRequestException({ - error: 'INVALID_MAX_ATTEMPTS', - message: 'maxAttempts must be a positive integer', - }); - } - this.maxAttempts.set(normalizedId, max); - } - - /** - * Return the effective max attempt limit for a challenge. - * Falls back to `DEFAULT_MAX_ATTEMPTS` when no explicit limit has been set. - */ - getMaxAttempts(challengeId: string): number { - const normalizedId = this.normalizeId(challengeId, 'challengeId'); - return this.maxAttempts.get(normalizedId) ?? DEFAULT_MAX_ATTEMPTS; - } - - /** - * Check whether the user is allowed to submit another attempt. - */ - checkAttemptLimit(challengeId: string, userId: string): AttemptInfo { - const normalizedChallengeId = this.normalizeId(challengeId, 'challengeId'); - const normalizedUserId = this.normalizeId(userId, 'userId'); - const max = this.getMaxAttempts(normalizedChallengeId); - const current = this.attemptCounts.get(normalizedChallengeId)?.get(normalizedUserId) ?? 0; - - return { - challengeId: normalizedChallengeId, - userId: normalizedUserId, - current, - max, - remaining: Math.max(0, max - current), - allowed: current < max, - }; - } - - /** - * Record an attempt for the user on the given challenge. - * - * @returns The updated attempt count for this user+challenge. - * @throws BadRequestException if the user has already exhausted their attempts. - */ - recordAttempt(challengeId: string, userId: string): number { - const info = this.checkAttemptLimit(challengeId, userId); - - if (!info.allowed) { - throw new BadRequestException({ - error: 'ATTEMPT_LIMIT_EXCEEDED', - message: `Maximum attempts (${info.max}) exhausted for challenge "${info.challengeId}"`, - ...info, - }); - } - - let perChallenge = this.attemptCounts.get(info.challengeId); - if (!perChallenge) { - perChallenge = new Map(); - this.attemptCounts.set(info.challengeId, perChallenge); - } - - const nextCount = info.current + 1; - perChallenge.set(info.userId, nextCount); - return nextCount; - } - - /** - * Get attempt information for a user on a challenge (read-only). - */ - getAttemptInfo(challengeId: string, userId: string): AttemptInfo { - return this.checkAttemptLimit(challengeId, userId); - } - - /** - * Reset attempt counters for all challenges (useful in tests or when - * a challenge period ends). - */ - resetAttempts(): void { - this.attemptCounts.clear(); - } - - /** - * Reset attempt counters for a specific challenge. - */ - resetAttemptsForChallenge(challengeId: string): void { - const normalizedId = this.normalizeId(challengeId, 'challengeId'); - this.attemptCounts.delete(normalizedId); - } - - // --------------------------------------------------------------------------- - // Internal helpers - // --------------------------------------------------------------------------- - - private normalizeId(value: string | undefined, field: string): string { - const normalized = value?.trim(); - if (!normalized) { - throw new BadRequestException({ - error: 'INVALID_CHALLENGE_VOTE', - message: `${field} is required`, - }); - } - return normalized; - } - - private normalizeVote(value: ChallengeVoteValue): ChallengeVoteValue { - if (value !== 'up' && value !== 'down') { - throw new BadRequestException({ - error: 'INVALID_CHALLENGE_VOTE', - message: 'value must be either "up" or "down"', - }); - } - return value; - } -} diff --git a/BackendAcademy/src/challenges/dto/cast-challenge-vote.dto.ts b/BackendAcademy/src/challenges/dto/cast-challenge-vote.dto.ts deleted file mode 100644 index f114187be4..0000000000 --- a/BackendAcademy/src/challenges/dto/cast-challenge-vote.dto.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { IsString, IsIn } from 'class-validator'; - -export class CastChallengeVoteDto { - @IsString() - userId: string; - - @IsIn(['up', 'down']) - value: 'up' | 'down'; -} diff --git a/BackendAcademy/src/challenges/index.ts b/BackendAcademy/src/challenges/index.ts deleted file mode 100644 index 19bfc043a9..0000000000 --- a/BackendAcademy/src/challenges/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export { ChallengesController } from './challenges.controller'; -export { ChallengesModule } from './challenges.module'; -export { ChallengesService } from './challenges.service'; -export { CastChallengeVoteDto } from './dto/cast-challenge-vote.dto'; -export type { - ChallengeVoteResponse, - ChallengeVoteTally, - ChallengeVoteValue, -} from './interfaces/challenge-vote.interface'; diff --git a/BackendAcademy/src/challenges/interfaces/challenge-vote.interface.ts b/BackendAcademy/src/challenges/interfaces/challenge-vote.interface.ts deleted file mode 100644 index a922a3db9b..0000000000 --- a/BackendAcademy/src/challenges/interfaces/challenge-vote.interface.ts +++ /dev/null @@ -1,14 +0,0 @@ -export type ChallengeVoteValue = 'up' | 'down'; - -export interface ChallengeVoteTally { - challengeId: string; - downvotes: number; - score: number; - totalVotes: number; - upvotes: number; -} - -export interface ChallengeVoteResponse extends ChallengeVoteTally { - userId: string; - userVote: ChallengeVoteValue; -} diff --git a/BackendAcademy/src/challenges/interfaces/weekly-challenge-metadata.interface.ts b/BackendAcademy/src/challenges/interfaces/weekly-challenge-metadata.interface.ts deleted file mode 100644 index 4760df4973..0000000000 --- a/BackendAcademy/src/challenges/interfaces/weekly-challenge-metadata.interface.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** Weekly challenge metadata returned by the challenges API. */ -export interface WeeklyChallengeMetadata { - id: string; - title: string; - description: string; - startDate: string; - endDate: string; - rewardXp: number; - bonusDescription: string; - isActive: boolean; -} diff --git a/BackendAcademy/src/chat/chat-rate-limit.ts b/BackendAcademy/src/chat/chat-rate-limit.ts deleted file mode 100644 index effe2902fa..0000000000 --- a/BackendAcademy/src/chat/chat-rate-limit.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Session-based chat rate limiting. - * - * Tracks message timestamps per session (keyed by senderId) using a sliding - * window so a single user cannot flood chat rooms. This is scoped to the chat - * module and is independent of the global ThrottlerGuard, which limits by IP. - */ -export interface ChatRateLimitConfig { - /** Maximum number of messages allowed within the window. */ - maxMessages: number; - /** Length of the sliding window in milliseconds. */ - windowMs: number; -} - -export const DEFAULT_CHAT_RATE_LIMIT: ChatRateLimitConfig = { - maxMessages: 10, - windowMs: 10_000, -}; - -export class ChatRateLimiter { - private readonly hits = new Map(); - - constructor( - private readonly config: ChatRateLimitConfig = DEFAULT_CHAT_RATE_LIMIT, - ) {} - - /** - * Records an attempt for the given session and reports whether it is allowed. - * Returns the number of seconds to wait when the limit has been exceeded. - */ - check(sessionId: string, now: number = Date.now()): { - allowed: boolean; - retryAfterSeconds: number; - } { - const windowStart = now - this.config.windowMs; - const recent = (this.hits.get(sessionId) ?? []).filter( - (timestamp) => timestamp > windowStart, - ); - - if (recent.length >= this.config.maxMessages) { - this.hits.set(sessionId, recent); - const oldest = recent[0]; - const retryAfterMs = oldest + this.config.windowMs - now; - return { - allowed: false, - retryAfterSeconds: Math.max(1, Math.ceil(retryAfterMs / 1000)), - }; - } - - recent.push(now); - this.hits.set(sessionId, recent); - return { allowed: true, retryAfterSeconds: 0 }; - } - - /** Clears all tracked sessions (primarily for testing). */ - reset(): void { - this.hits.clear(); - } -} diff --git a/BackendAcademy/src/chat/chat.controller.ts b/BackendAcademy/src/chat/chat.controller.ts deleted file mode 100644 index 60664f07f2..0000000000 --- a/BackendAcademy/src/chat/chat.controller.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Controller, Get, Post, Body, Param, Query } from '@nestjs/common'; -import { ChatService } from './chat.service'; -import { CreateMessageDto } from './dto/create-message.dto'; -import { CreateRoomDto } from './dto/create-room.dto'; -import { ShareCodeSnippetDto } from './dto/share-code-snippet.dto'; -import { PageOptions } from './repositories/chat.repository.interface'; - -@Controller('chat') -export class ChatController { - constructor(private readonly chatService: ChatService) {} - - @Post('rooms') - createRoom(@Body() createRoomDto: CreateRoomDto) { - return this.chatService.createRoom(createRoomDto); - } - - @Get('rooms') - findAllRooms( - @Query('limit') limit?: string, - @Query('cursor') cursor?: string, - ) { - const options: PageOptions = { - limit: limit ? Number(limit) : undefined, - cursor, - }; - return this.chatService.findAllRooms(options); - } - - @Get('rooms/:roomId') - findRoom(@Param('roomId') roomId: string) { - return this.chatService.findRoomById(roomId); - } - - @Post('messages') - createMessage(@Body() createMessageDto: CreateMessageDto) { - return this.chatService.createMessage(createMessageDto); - } - - @Get('rooms/:roomId/messages') - findMessagesByRoom( - @Param('roomId') roomId: string, - @Query('limit') limit?: string, - @Query('cursor') cursor?: string, - ) { - const options: PageOptions = { - limit: limit ? Number(limit) : undefined, - cursor, - }; - return this.chatService.findMessagesByRoom(roomId, options); - } - - @Post('messages/share-code') - shareCodeSnippet(@Body() shareCodeSnippetDto: ShareCodeSnippetDto) { - return this.chatService.shareCodeSnippet(shareCodeSnippetDto); - } -} diff --git a/BackendAcademy/src/chat/chat.module.ts b/BackendAcademy/src/chat/chat.module.ts deleted file mode 100644 index a1cd54d275..0000000000 --- a/BackendAcademy/src/chat/chat.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ChatController } from './chat.controller'; -import { ChatService } from './chat.service'; -import { SecurityModule } from '../security/security.module'; - -@Module({ - imports: [SecurityModule], - controllers: [ChatController], - providers: [ChatService], - exports: [ChatService], -}) -export class ChatModule {} diff --git a/BackendAcademy/src/chat/chat.service.spec.ts b/BackendAcademy/src/chat/chat.service.spec.ts deleted file mode 100644 index a7cddef7f1..0000000000 --- a/BackendAcademy/src/chat/chat.service.spec.ts +++ /dev/null @@ -1,296 +0,0 @@ -import { ForbiddenException, HttpException, HttpStatus, NotFoundException } from '@nestjs/common'; -import { ChatService } from './chat.service'; -import { - ChatRateLimiter, - DEFAULT_CHAT_RATE_LIMIT, -} from './chat-rate-limit'; -import { InMemoryChatRepository } from './repositories/chat.repository.in-memory'; - -/** Creates a room and returns its id. */ -function createRoom(service: ChatService, participants: string[], name = 'room-1'): string { - const room = service.createRoom({ name, type: 'room', participants }); - return room.id; -} - -describe('ChatRateLimiter', () => { - it('allows messages up to the configured limit then blocks', () => { - const limiter = new ChatRateLimiter({ maxMessages: 3, windowMs: 1000 }); - const now = 1_000_000; - - expect(limiter.check('session-1', now).allowed).toBe(true); - expect(limiter.check('session-1', now).allowed).toBe(true); - expect(limiter.check('session-1', now).allowed).toBe(true); - - const blocked = limiter.check('session-1', now); - expect(blocked.allowed).toBe(false); - expect(blocked.retryAfterSeconds).toBeGreaterThan(0); - }); - - it('tracks each session independently', () => { - const limiter = new ChatRateLimiter({ maxMessages: 1, windowMs: 1000 }); - const now = 1_000_000; - - expect(limiter.check('session-a', now).allowed).toBe(true); - expect(limiter.check('session-a', now).allowed).toBe(false); - // A different session is unaffected by session-a's usage. - expect(limiter.check('session-b', now).allowed).toBe(true); - }); - - it('allows messages again once the window has elapsed', () => { - const limiter = new ChatRateLimiter({ maxMessages: 1, windowMs: 1000 }); - const start = 1_000_000; - - expect(limiter.check('session-1', start).allowed).toBe(true); - expect(limiter.check('session-1', start).allowed).toBe(false); - // Past the window the slate is clean. - expect(limiter.check('session-1', start + 1001).allowed).toBe(true); - }); -}); - -describe('ChatService rate limiting', () => { - it('rejects messages once a session exceeds the limit', () => { - const service = new ChatService(); - const roomId = createRoom(service, ['spammer']); - const send = () => - service.createMessage({ - roomId, - senderId: 'spammer', - content: 'hi', - }); - - for (let i = 0; i < DEFAULT_CHAT_RATE_LIMIT.maxMessages; i++) { - expect(() => send()).not.toThrow(); - } - - expect(() => send()).toThrow(HttpException); - try { - send(); - } catch (err) { - expect((err as HttpException).getStatus()).toBe( - HttpStatus.TOO_MANY_REQUESTS, - ); - } - }); - - it('shares the limit across createMessage and shareCodeSnippet', () => { - const service = new ChatService(); - const roomId = createRoom(service, ['user-1']); - for (let i = 0; i < DEFAULT_CHAT_RATE_LIMIT.maxMessages; i++) { - service.createMessage({ - roomId, - senderId: 'user-1', - content: 'hi', - }); - } - - expect(() => - service.shareCodeSnippet({ - roomId, - senderId: 'user-1', - content: 'snippet', - code: 'fn main() {}', - language: 'rust', - }), - ).toThrow(HttpException); - }); -}); - -describe('ChatService code snippet sharing', () => { - it('creates a shared code snippet message with metadata', () => { - const service = new ChatService(); - const roomId = createRoom(service, ['user-1']); - - const result = service.shareCodeSnippet({ - roomId, - senderId: 'user-1', - content: 'Shared a Rust snippet', - code: 'fn main() { println!("hi"); }', - language: 'rust', - title: 'Hello World', - }); - - expect(result).toMatchObject({ - roomId, - senderId: 'user-1', - content: 'Shared a Rust snippet', - codeSnippet: { - code: 'fn main() { println!("hi"); }', - language: 'rust', - title: 'Hello World', - }, - }); - - const roomMessages = service.findMessagesByRoom(roomId); - expect(roomMessages.items).toHaveLength(1); - expect(roomMessages.items[0].codeSnippet).toEqual({ - code: 'fn main() { println!("hi"); }', - language: 'rust', - title: 'Hello World', - }); - }); -}); - -describe('ChatService authorization (#655)', () => { - it('rejects messages for a room that does not exist', () => { - const service = new ChatService(); - expect(() => - service.createMessage({ - roomId: 'missing-room', - senderId: 'user-1', - content: 'hi', - }), - ).toThrow(NotFoundException); - }); - - it('rejects messages from a sender who is not a room participant', () => { - const service = new ChatService(); - const roomId = createRoom(service, ['alice']); - - expect(() => - service.createMessage({ - roomId, - senderId: 'mallory', - content: 'hi', - }), - ).toThrow(ForbiddenException); - }); - - it('rejects code snippets from a sender who is not a room participant', () => { - const service = new ChatService(); - const roomId = createRoom(service, ['alice']); - - expect(() => - service.shareCodeSnippet({ - roomId, - senderId: 'mallory', - content: 'snippet', - code: 'fn main() {}', - language: 'rust', - }), - ).toThrow(ForbiddenException); - }); - - it('rejects reading messages for a room that does not exist', () => { - const service = new ChatService(); - expect(() => service.findMessagesByRoom('missing-room')).toThrow( - NotFoundException, - ); - }); - - it('allows participants to post to a room', () => { - const service = new ChatService(); - const roomId = createRoom(service, ['alice', 'bob']); - - const msg = service.createMessage({ - roomId, - senderId: 'bob', - content: 'hello alice', - }); - expect(msg.senderId).toBe('bob'); - }); -}); - -describe('ChatService pagination (#655)', () => { - it('pages messages with a stable cursor', () => { - const service = new ChatService(); - const roomId = createRoom(service, ['user-1']); - for (let i = 0; i < 5; i++) { - service.createMessage({ roomId, senderId: 'user-1', content: `msg-${i}` }); - } - - const page1 = service.findMessagesByRoom(roomId, { limit: 2 }); - expect(page1.items).toHaveLength(2); - expect(page1.nextCursor).toBeDefined(); - - const page2 = service.findMessagesByRoom(roomId, { - limit: 2, - cursor: page1.nextCursor, - }); - expect(page2.items).toHaveLength(2); - expect(page2.nextCursor).toBeDefined(); - - const page3 = service.findMessagesByRoom(roomId, { - limit: 2, - cursor: page2.nextCursor, - }); - expect(page3.items).toHaveLength(1); - expect(page3.nextCursor).toBeUndefined(); - }); - - it('pages rooms newest first', () => { - const service = new ChatService(); - createRoom(service, ['u1'], 'room-a'); - createRoom(service, ['u2'], 'room-b'); - createRoom(service, ['u3'], 'room-c'); - - const page1 = service.findAllRooms({ limit: 2 }); - expect(page1.items).toHaveLength(2); - expect(page1.nextCursor).toBeDefined(); - - const page2 = service.findAllRooms({ limit: 2, cursor: page1.nextCursor }); - expect(page2.items).toHaveLength(1); - expect(page2.nextCursor).toBeUndefined(); - }); -}); - -describe('ChatService bounded storage (#655)', () => { - it('evicts the oldest messages once a room exceeds its bound', () => { - const repository = new InMemoryChatRepository({ - maxRooms: 10, - maxMessagesPerRoom: 3, - roomTtlMs: 60_000, - messageTtlMs: 60_000, - }); - const service = new ChatService(undefined, repository); - const roomId = createRoom(service, ['user-1']); - - for (let i = 0; i < 5; i++) { - service.createMessage({ roomId, senderId: 'user-1', content: `msg-${i}` }); - } - - const { items } = service.findMessagesByRoom(roomId); - expect(items).toHaveLength(3); - // Oldest messages were evicted first. - expect(items.map((m) => m.content)).toEqual(['msg-2', 'msg-3', 'msg-4']); - }); - - it('evicts the oldest room (and its messages) once the room bound is exceeded', () => { - const repository = new InMemoryChatRepository({ - maxRooms: 2, - maxMessagesPerRoom: 10, - roomTtlMs: 60_000, - messageTtlMs: 60_000, - }); - const service = new ChatService(undefined, repository); - - const room1 = createRoom(service, ['u1'], 'room-1'); - service.createMessage({ roomId: room1, senderId: 'u1', content: 'hi' }); - const room2 = createRoom(service, ['u2'], 'room-2'); - const room3 = createRoom(service, ['u3'], 'room-3'); - - expect(service.findRoomById(room1)).toBeUndefined(); - expect(service.findRoomById(room2)).toBeDefined(); - expect(service.findRoomById(room3)).toBeDefined(); - // Messages of the evicted room are gone too. - expect(repository.messageCount).toBe(0); - }); - - it('prunes messages and rooms past their retention window', () => { - const repository = new InMemoryChatRepository({ - maxRooms: 10, - maxMessagesPerRoom: 10, - roomTtlMs: 1_000, - messageTtlMs: 1_000, - }); - const service = new ChatService(undefined, repository); - const roomId = createRoom(service, ['user-1']); - service.createMessage({ roomId, senderId: 'user-1', content: 'hi' }); - - // A "now" far in the future expires everything. - service.pruneExpired(new Date(Date.now() + 60_000)); - - expect(repository.messageCount).toBe(0); - expect(repository.roomCount).toBe(0); - expect(service.findRoomById(roomId)).toBeUndefined(); - }); -}); diff --git a/BackendAcademy/src/chat/chat.service.ts b/BackendAcademy/src/chat/chat.service.ts deleted file mode 100644 index f782790e7c..0000000000 --- a/BackendAcademy/src/chat/chat.service.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { - ForbiddenException, - HttpException, - HttpStatus, - Injectable, - Logger, - NotFoundException, - Optional, -} from '@nestjs/common'; -import { ChatRoom, Message } from './interfaces/chat.interface'; -import { CreateMessageDto } from './dto/create-message.dto'; -import { CreateRoomDto } from './dto/create-room.dto'; -import { ShareCodeSnippetDto } from './dto/share-code-snippet.dto'; -import { ChatRateLimiter } from './chat-rate-limit'; -import { SecurityService } from '../security/security.service'; -import { - ChatPage, - IChatRepository, - PageOptions, -} from './repositories/chat.repository.interface'; -import { InMemoryChatRepository } from './repositories/chat.repository.in-memory'; - -/** - * Chat service (#655). - * - * Storage is delegated to an {@link IChatRepository} that keeps rooms and - * messages in **bounded** storage with retention, eviction, and pagination — - * process-local arrays no longer grow without limit. This service layer adds - * the business rules on top: - * - Rate limiting per sender (shared across messages and code snippets). - * - Prompt sanitisation for long content (#371). - * - **Authorization**: posting to a room requires the room to exist and the - * sender to be listed in the room's `participants`; reading a room's - * messages requires the room to exist. - */ -@Injectable() -export class ChatService { - private readonly logger = new Logger(ChatService.name); - private readonly rateLimiter = new ChatRateLimiter(); - private readonly repository: IChatRepository; - - /** - * Length above which outgoing chat messages are run through the prompt - * sanitiser (Issue #371). Short greetings / status pings are left alone - * to avoid adding per-message latency to the common case. - */ - private static readonly SANITISE_THRESHOLD = 280; - - constructor( - @Optional() private readonly securityService?: SecurityService, - @Optional() repository?: IChatRepository, - ) { - this.repository = repository ?? new InMemoryChatRepository(); - } - - createRoom(createRoomDto: CreateRoomDto): ChatRoom { - const newRoom: ChatRoom = { - id: Math.random().toString(36).substring(2, 9), - ...createRoomDto, - createdAt: new Date(), - }; - return this.repository.createRoom(newRoom); - } - - findAllRooms(options: PageOptions = {}): ChatPage { - return this.repository.findAllRooms(options); - } - - findRoomById(roomId: string): ChatRoom | undefined { - return this.repository.findRoomById(roomId); - } - - createMessage(createMessageDto: CreateMessageDto): Message { - this.assertCanPost(createMessageDto.roomId, createMessageDto.senderId); - this.enforceRateLimit(createMessageDto.senderId); - const sanitisedContent = this.sanitiseIfNeeded(createMessageDto.content); - const newMessage: Message = { - id: Math.random().toString(36).substring(2, 9), - ...createMessageDto, - content: sanitisedContent.content, - createdAt: new Date(), - }; - return this.repository.createMessage(newMessage); - } - - shareCodeSnippet(shareCodeSnippetDto: ShareCodeSnippetDto): Message { - this.assertCanPost(shareCodeSnippetDto.roomId, shareCodeSnippetDto.senderId); - this.enforceRateLimit(shareCodeSnippetDto.senderId); - const sanitisedContent = this.sanitiseIfNeeded(shareCodeSnippetDto.content); - const newMessage: Message = { - id: Math.random().toString(36).substring(2, 9), - ...shareCodeSnippetDto, - content: sanitisedContent.content, - codeSnippet: { - code: shareCodeSnippetDto.code, - language: shareCodeSnippetDto.language, - title: shareCodeSnippetDto.title, - }, - createdAt: new Date(), - }; - - return this.repository.createMessage(newMessage); - } - - findMessagesByRoom(roomId: string, options: PageOptions = {}): ChatPage { - if (!this.repository.findRoomById(roomId)) { - throw new NotFoundException(`Chat room ${roomId} not found`); - } - return this.repository.findMessagesByRoom(roomId, options); - } - - /** - * #373: Marks in-flight chat messages as incomplete when a streaming - * disconnect is detected. This prevents partial AI responses from - * persisting in the conversation history. - * - * Messages flagged as incomplete can be filtered out by the frontend - * or cleaned up by the `cleanupIncompleteMessages` method. - */ - markStreamingDisconnect(messageId: string): boolean { - return this.repository.markStreamingDisconnect(messageId); - } - - /** - * #373: Removes all incomplete messages from the in-memory store that - * were abandoned due to streaming disconnects. - */ - cleanupIncompleteMessages(): number { - return this.repository.cleanupIncompleteMessages(); - } - - /** - * #655: Prunes rooms/messages that have exceeded their retention window. - * Called lazily by the repository on every read/write; exposed here for - * maintenance/cleanup jobs. - */ - pruneExpired(now?: Date): void { - this.repository.pruneExpired(now); - } - - /** - * #655: Authorization rule for posting — the room must exist and the - * sender must be one of its participants. - */ - private assertCanPost(roomId: string, senderId: string): void { - const room = this.repository.findRoomById(roomId); - if (!room) { - throw new NotFoundException(`Chat room ${roomId} not found`); - } - if (!room.participants.includes(senderId)) { - throw new ForbiddenException( - `Sender ${senderId} is not a participant of room ${roomId}`, - ); - } - } - - /** - * Run outgoing chat content through the SecurityService prompt sanitiser - * when the content is long enough that it might plausibly be AI-bound or - * carry instructions. The original behaviour is preserved when no - * SecurityService is wired in (e.g. unit tests). - */ - private sanitiseIfNeeded(content: string): { content: string } { - if (!this.securityService || !content || content.length < ChatService.SANITISE_THRESHOLD) { - return { content }; - } - const result = this.securityService.sanitisePrompt(content); - if (result.status === 'safe') { - return { content }; - } - if (result.status === 'rejected') { - this.logger.warn( - `[#371] chat message blocked — reasons=${result.reasons.join(',')}`, - ); - return { content: result.sanitised }; - } - this.logger.debug( - `[#371] chat message wrapped — reasons=${result.reasons.join(',')}`, - ); - return { content: result.sanitised }; - } - - private enforceRateLimit(senderId: string): void { - const { allowed, retryAfterSeconds } = this.rateLimiter.check(senderId); - if (!allowed) { - throw new HttpException( - { - statusCode: HttpStatus.TOO_MANY_REQUESTS, - message: 'Chat rate limit exceeded. Please slow down.', - retryAfter: retryAfterSeconds, - }, - HttpStatus.TOO_MANY_REQUESTS, - ); - } - } -} diff --git a/BackendAcademy/src/chat/dto/create-message.dto.ts b/BackendAcademy/src/chat/dto/create-message.dto.ts deleted file mode 100644 index 0895be64fd..0000000000 --- a/BackendAcademy/src/chat/dto/create-message.dto.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { IsNotEmpty, IsString } from 'class-validator'; - -export class CreateMessageDto { - @IsString() - @IsNotEmpty() - roomId: string; - - @IsString() - @IsNotEmpty() - senderId: string; - - @IsString() - @IsNotEmpty() - content: string; -} diff --git a/BackendAcademy/src/chat/dto/create-room.dto.ts b/BackendAcademy/src/chat/dto/create-room.dto.ts deleted file mode 100644 index fdda48e562..0000000000 --- a/BackendAcademy/src/chat/dto/create-room.dto.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { - ArrayNotEmpty, - IsArray, - IsIn, - IsNotEmpty, - IsOptional, - IsString, -} from 'class-validator'; - -export class CreateRoomDto { - @IsOptional() - @IsString() - name?: string; - - @IsIn(['direct', 'room', 'course']) - type: 'direct' | 'room' | 'course'; - - @IsArray() - @ArrayNotEmpty() - @IsString({ each: true }) - @IsNotEmpty({ each: true }) - participants: string[]; - - @IsOptional() - @IsString() - courseId?: string; -} diff --git a/BackendAcademy/src/chat/dto/share-code-snippet.dto.ts b/BackendAcademy/src/chat/dto/share-code-snippet.dto.ts deleted file mode 100644 index 232ce388d3..0000000000 --- a/BackendAcademy/src/chat/dto/share-code-snippet.dto.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; - -export class ShareCodeSnippetDto { - @IsString() - @IsNotEmpty() - roomId: string; - - @IsString() - @IsNotEmpty() - senderId: string; - - @IsString() - @IsNotEmpty() - content: string; - - @IsString() - @IsNotEmpty() - code: string; - - @IsOptional() - @IsString() - language?: string; - - @IsOptional() - @IsString() - title?: string; -} diff --git a/BackendAcademy/src/chat/interfaces/chat.interface.ts b/BackendAcademy/src/chat/interfaces/chat.interface.ts deleted file mode 100644 index 7e72bc8a43..0000000000 --- a/BackendAcademy/src/chat/interfaces/chat.interface.ts +++ /dev/null @@ -1,25 +0,0 @@ -export type ChatType = 'direct' | 'room' | 'course'; - -export interface ChatRoom { - id: string; - name?: string; - type: ChatType; - participants: string[]; - createdAt: Date; - courseId?: string; -} - -export interface CodeSnippet { - code: string; - language?: string; - title?: string; -} - -export interface Message { - id: string; - roomId: string; - senderId: string; - content: string; - codeSnippet?: CodeSnippet; - createdAt: Date; -} diff --git a/BackendAcademy/src/chat/repositories/chat.repository.in-memory.ts b/BackendAcademy/src/chat/repositories/chat.repository.in-memory.ts deleted file mode 100644 index 5100eb6587..0000000000 --- a/BackendAcademy/src/chat/repositories/chat.repository.in-memory.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { ChatRoom, Message } from '../interfaces/chat.interface'; -import { - ChatPage, - ChatStorageConfig, - DEFAULT_CHAT_STORAGE_CONFIG, - IChatRepository, - PageOptions, -} from './chat.repository.interface'; - -/** Hard cap applied to any requested page size. */ -const MAX_PAGE_SIZE = 100; -const DEFAULT_PAGE_SIZE = 50; - -/** - * In-memory implementation of the chat repository (#655). - * - * Rooms and messages are stored in bounded, process-local arrays: - * - `maxRooms` / `maxMessagesPerRoom` cap growth; the oldest entries are - * evicted (with their messages) when a bound is exceeded. - * - `roomTtlMs` / `messageTtlMs` define the retention window; expired - * entries are pruned lazily on every read/write via {@link pruneExpired}. - * - List queries are paginated (see {@link paginate}). - */ -export class InMemoryChatRepository implements IChatRepository { - private rooms: ChatRoom[] = []; - private messages: Message[] = []; - - constructor( - private readonly config: ChatStorageConfig = DEFAULT_CHAT_STORAGE_CONFIG, - ) {} - - createRoom(room: ChatRoom): ChatRoom { - this.pruneExpired(); - this.rooms.push(room); - this.enforceRoomBounds(); - return room; - } - - findAllRooms(options: PageOptions = {}): ChatPage { - this.pruneExpired(); - const sorted = [...this.rooms].sort( - (a, b) => b.createdAt.getTime() - a.createdAt.getTime(), - ); - return this.paginate(sorted, options); - } - - findRoomById(roomId: string): ChatRoom | undefined { - this.pruneExpired(); - return this.rooms.find((r) => r.id === roomId); - } - - createMessage(message: Message): Message { - this.pruneExpired(); - this.messages.push(message); - this.enforceMessageBounds(message.roomId); - return message; - } - - findMessagesByRoom(roomId: string, options: PageOptions = {}): ChatPage { - this.pruneExpired(); - const roomMessages = this.messages - .filter((m) => m.roomId === roomId) - .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()); - return this.paginate(roomMessages, options); - } - - markStreamingDisconnect(messageId: string): boolean { - const msg = this.messages.find((m) => m.id === messageId); - if (!msg) return false; - (msg as any).streamingComplete = false; - (msg as any).streamingAbortedAt = new Date(); - return true; - } - - cleanupIncompleteMessages(): number { - const before = this.messages.length; - this.messages = this.messages.filter( - (m) => (m as any).streamingComplete !== false, - ); - return before - this.messages.length; - } - - /** - * Removes rooms and messages that have exceeded their retention window. - * Expiring a room also drops all of its messages. - */ - pruneExpired(now: Date = new Date()): void { - const cutoff = now.getTime(); - - this.messages = this.messages.filter( - (m) => cutoff - m.createdAt.getTime() < this.config.messageTtlMs, - ); - - const activeRoomIds = new Set(); - this.rooms = this.rooms.filter((r) => { - const keep = cutoff - r.createdAt.getTime() < this.config.roomTtlMs; - if (keep) activeRoomIds.add(r.id); - return keep; - }); - this.messages = this.messages.filter((m) => activeRoomIds.has(m.roomId)); - } - - clearAll(): void { - this.rooms = []; - this.messages = []; - } - - /** Number of rooms currently stored (test/observability helper). */ - get roomCount(): number { - return this.rooms.length; - } - - /** Number of messages currently stored (test/observability helper). */ - get messageCount(): number { - return this.messages.length; - } - - private enforceRoomBounds(): void { - while (this.rooms.length > this.config.maxRooms) { - const oldest = this.rooms.reduce((a, b) => - a.createdAt.getTime() <= b.createdAt.getTime() ? a : b, - ); - this.removeRoom(oldest.id); - } - } - - private enforceMessageBounds(roomId: string): void { - const roomMessages = this.messages.filter((m) => m.roomId === roomId); - const excess = roomMessages.length - this.config.maxMessagesPerRoom; - if (excess <= 0) return; - - const evictIds = new Set( - roomMessages - .sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime()) - .slice(0, excess) - .map((m) => m.id), - ); - this.messages = this.messages.filter((m) => !evictIds.has(m.id)); - } - - private removeRoom(roomId: string): void { - this.rooms = this.rooms.filter((r) => r.id !== roomId); - this.messages = this.messages.filter((m) => m.roomId !== roomId); - } - - private paginate( - items: T[], - options: PageOptions, - ): ChatPage { - const limit = Math.min( - Math.max(1, options.limit ?? DEFAULT_PAGE_SIZE), - MAX_PAGE_SIZE, - ); - - let startIndex = 0; - if (options.cursor) { - const idx = items.findIndex((item) => item.id === options.cursor); - if (idx !== -1) startIndex = idx + 1; - } - - const page = items.slice(startIndex, startIndex + limit); - const more = startIndex + page.length < items.length; - const nextCursor = page.length === limit && more - ? page[page.length - 1].id - : undefined; - - return { items: page, nextCursor }; - } -} diff --git a/BackendAcademy/src/chat/repositories/chat.repository.interface.ts b/BackendAcademy/src/chat/repositories/chat.repository.interface.ts deleted file mode 100644 index d4edd46d78..0000000000 --- a/BackendAcademy/src/chat/repositories/chat.repository.interface.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { ChatRoom, Message } from '../interfaces/chat.interface'; - -/** - * A single page of results from a paginated chat query. - */ -export interface ChatPage { - items: T[]; - /** Opaque cursor for fetching the next page. Undefined when no more pages. */ - nextCursor?: string; -} - -/** - * Pagination options for chat list endpoints (#655). - */ -export interface PageOptions { - /** Maximum number of items to return (clamped to [1, 100]). */ - limit?: number; - /** Opaque cursor returned by a previous page. */ - cursor?: string; -} - -/** - * Storage limits and retention policy for chat data (#655). - * - * Rooms and messages are kept in bounded, process-local storage: when a - * limit is exceeded the oldest entries are evicted, and entries older than - * their retention window are pruned lazily on every read/write. - */ -export interface ChatStorageConfig { - /** Maximum number of rooms retained at once. */ - maxRooms: number; - /** Maximum number of messages retained per room. */ - maxMessagesPerRoom: number; - /** How long a room is retained before it expires (ms). */ - roomTtlMs: number; - /** How long a message is retained before it expires (ms). */ - messageTtlMs: number; -} - -export const DEFAULT_CHAT_STORAGE_CONFIG: ChatStorageConfig = { - maxRooms: 1000, - maxMessagesPerRoom: 500, - roomTtlMs: 30 * 24 * 60 * 60 * 1000, // 30 days - messageTtlMs: 7 * 24 * 60 * 60 * 1000, // 7 days -}; - -/** - * Repository interface for chat storage. - * Isolates persistence concerns from business logic. - */ -export interface IChatRepository { - /** - * Create a new chat room. Enforces the room bound and retention policy. - */ - createRoom(room: ChatRoom): ChatRoom; - - /** - * Get chat rooms (newest first), paginated. - */ - findAllRooms(options?: PageOptions): ChatPage; - - /** - * Find a specific room by ID. - */ - findRoomById(roomId: string): ChatRoom | undefined; - - /** - * Create a new message. Enforces the per-room message bound and retention. - */ - createMessage(message: Message): Message; - - /** - * Get all messages for a specific room (oldest first), paginated. - */ - findMessagesByRoom(roomId: string, options?: PageOptions): ChatPage; - - /** - * Mark a message as incomplete due to streaming disconnect. - */ - markStreamingDisconnect(messageId: string): boolean; - - /** - * Remove all incomplete messages. - */ - cleanupIncompleteMessages(): number; - - /** - * Prune rooms/messages that have exceeded their retention window. - */ - pruneExpired(now?: Date): void; - - /** - * Clear all chat data (useful for testing). - */ - clearAll(): void; -} diff --git a/BackendAcademy/src/chat/repositories/index.ts b/BackendAcademy/src/chat/repositories/index.ts deleted file mode 100644 index 445feb1cb0..0000000000 --- a/BackendAcademy/src/chat/repositories/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { IChatRepository } from './chat.repository.interface'; -export { InMemoryChatRepository } from './chat.repository.in-memory'; diff --git a/BackendAcademy/src/common/correlation-id.middleware.ts b/BackendAcademy/src/common/correlation-id.middleware.ts deleted file mode 100644 index dd5c6bd927..0000000000 --- a/BackendAcademy/src/common/correlation-id.middleware.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Injectable, NestMiddleware } from '@nestjs/common'; -import { Request, Response, NextFunction } from 'express'; -import { v4 as uuidv4 } from 'uuid'; -import { CorrelationLoggerService } from '../logging/logger.service'; - -@Injectable() -export class CorrelationIdMiddleware implements NestMiddleware { - use(req: Request, _res: Response, next: NextFunction): void { - const correlationId = (req.headers['x-correlation-id'] as string) || uuidv4(); - const userId = (req as any).user?.id; - const tenantId = - (req.headers['x-tenant-id'] as string) || - (req as any).user?.tenantId || - undefined; - - req.headers['x-correlation-id'] = correlationId; - _res.setHeader('x-correlation-id', correlationId); - - CorrelationLoggerService.run( - { correlationId, userId, requestId: correlationId, tenantId }, - () => next(), - ); - } -} \ No newline at end of file diff --git a/BackendAcademy/src/common/error-codes.constants.ts b/BackendAcademy/src/common/error-codes.constants.ts deleted file mode 100644 index eae7582058..0000000000 --- a/BackendAcademy/src/common/error-codes.constants.ts +++ /dev/null @@ -1,107 +0,0 @@ -export enum ErrorCode { - SUCCESS = 'SUCCESS', - VALIDATION_ERROR = 'VALIDATION_ERROR', - NOT_FOUND = 'NOT_FOUND', - UNAUTHORIZED = 'UNAUTHORIZED', - FORBIDDEN = 'FORBIDDEN', - CONFLICT = 'CONFLICT', - RATE_LIMITED = 'RATE_LIMITED', - PAYMENT_FAILED = 'PAYMENT_FAILED', - PAYMENT_INSUFFICIENT_FUNDS = 'PAYMENT_INSUFFICIENT_FUNDS', - PAYMENT_CARD_DECLINED = 'PAYMENT_CARD_DECLINED', - PAYMENT_EXPIRED = 'PAYMENT_EXPIRED', - PAYMENT_INVALID_AMOUNT = 'PAYMENT_INVALID_AMOUNT', - SUBMISSION_NOT_FOUND = 'SUBMISSION_NOT_FOUND', - SUBMISSION_ALREADY_REVIEWED = 'SUBMISSION_ALREADY_REVIEWED', - SUBMISSION_NOT_REVIEWABLE = 'SUBMISSION_NOT_REVIEWABLE', - SUBMISSION_INVALID_STATUS = 'SUBMISSION_INVALID_STATUS', - COURSE_NOT_FOUND = 'COURSE_NOT_FOUND', - COURSE_ALREADY_ENROLLED = 'COURSE_ALREADY_ENROLLED', - COURSE_NOT_ENROLLED = 'COURSE_NOT_ENROLLED', - CHALLENGE_NOT_FOUND = 'CHALLENGE_NOT_FOUND', - CHALLENGE_ALREADY_COMPLETED = 'CHALLENGE_ALREADY_COMPLETED', - CHALLENGE_EXPIRED = 'CHALLENGE_EXPIRED', - USER_NOT_FOUND = 'USER_NOT_FOUND', - USER_ALREADY_EXISTS = 'USER_ALREADY_EXISTS', - USER_SUSPENDED = 'USER_SUSPENDED', - BADGE_ALREADY_EARNED = 'BADGE_ALREADY_EARNED', - BADGE_REQUIREMENTS_NOT_MET = 'BADGE_REQUIREMENTS_NOT_MET', - LEADERBOARD_NOT_AVAILABLE = 'LEADERBOARD_NOT_AVAILABLE', - REWARD_NOT_FOUND = 'REWARD_NOT_FOUND', - REWARD_ALREADY_CLAIMED = 'REWARD_ALREADY_CLAIMED', - REWARD_EXPIRED = 'REWARD_EXPIRED', - API_KEY_INVALID = 'API_KEY_INVALID', - API_KEY_EXPIRED = 'API_KEY_EXPIRED', - API_KEY_REVOKED = 'API_KEY_REVOKED', - API_KEY_RATE_LIMITED = 'API_KEY_RATE_LIMITED', - INTERNAL_ERROR = 'INTERNAL_ERROR', - SERVICE_UNAVAILABLE = 'SERVICE_UNAVAILABLE', - INVALID_INPUT = 'INVALID_INPUT', - MISSING_PARAMETER = 'MISSING_PARAMETER', - INVALID_PARAMETER = 'INVALID_PARAMETER', - RESOURCE_CONFLICT = 'RESOURCE_CONFLICT', - INSUFFICIENT_PERMISSIONS = 'INSUFFICIENT_PERMISSIONS', - SESSION_EXPIRED = 'SESSION_EXPIRED', - ACCOUNT_LOCKED = 'ACCOUNT_LOCKED', - TOO_MANY_REQUESTS = 'TOO_MANY_REQUESTS', - HTTP_EXCEPTION = 'HTTP_EXCEPTION', - HINT_NOT_FOUND = 'HINT_NOT_FOUND', - HINT_TIER_EXCEEDED = 'HINT_TIER_EXCEEDED', - HINT_CHALLENGE_INACCESSIBLE = 'HINT_CHALLENGE_INACCESSIBLE', - HINT_COOLDOWN_ACTIVE = 'HINT_COOLDOWN_ACTIVE', - HINT_MAX_VIEWS_EXCEEDED = 'HINT_MAX_VIEWS_EXCEEDED', -} - -export const ERROR_CODE_MESSAGES: Record = { - [ErrorCode.SUCCESS]: 'Success', - [ErrorCode.VALIDATION_ERROR]: 'The request body failed validation', - [ErrorCode.NOT_FOUND]: 'The requested resource was not found', - [ErrorCode.UNAUTHORIZED]: 'Authentication is required', - [ErrorCode.FORBIDDEN]: 'You do not have permission to access this resource', - [ErrorCode.CONFLICT]: 'The request conflicts with the current state', - [ErrorCode.RATE_LIMITED]: 'Too many requests, please try again later', - [ErrorCode.PAYMENT_FAILED]: 'Payment processing failed', - [ErrorCode.PAYMENT_INSUFFICIENT_FUNDS]: 'Insufficient funds for this transaction', - [ErrorCode.PAYMENT_CARD_DECLINED]: 'The payment card was declined', - [ErrorCode.PAYMENT_EXPIRED]: 'The payment session has expired', - [ErrorCode.PAYMENT_INVALID_AMOUNT]: 'The payment amount is invalid', - [ErrorCode.SUBMISSION_NOT_FOUND]: 'The submission was not found', - [ErrorCode.SUBMISSION_ALREADY_REVIEWED]: 'This submission has already been reviewed', - [ErrorCode.SUBMISSION_NOT_REVIEWABLE]: 'This submission cannot be reviewed in its current state', - [ErrorCode.SUBMISSION_INVALID_STATUS]: 'Invalid submission status transition', - [ErrorCode.COURSE_NOT_FOUND]: 'The course was not found', - [ErrorCode.COURSE_ALREADY_ENROLLED]: 'You are already enrolled in this course', - [ErrorCode.COURSE_NOT_ENROLLED]: 'You are not enrolled in this course', - [ErrorCode.CHALLENGE_NOT_FOUND]: 'The challenge was not found', - [ErrorCode.CHALLENGE_ALREADY_COMPLETED]: 'You have already completed this challenge', - [ErrorCode.CHALLENGE_EXPIRED]: 'This challenge has expired', - [ErrorCode.USER_NOT_FOUND]: 'The user was not found', - [ErrorCode.USER_ALREADY_EXISTS]: 'A user with this identifier already exists', - [ErrorCode.USER_SUSPENDED]: 'This user account has been suspended', - [ErrorCode.BADGE_ALREADY_EARNED]: 'You have already earned this badge', - [ErrorCode.BADGE_REQUIREMENTS_NOT_MET]: 'Badge requirements have not been met', - [ErrorCode.LEADERBOARD_NOT_AVAILABLE]: 'Leaderboard data is not available', - [ErrorCode.REWARD_NOT_FOUND]: 'The reward was not found', - [ErrorCode.REWARD_ALREADY_CLAIMED]: 'This reward has already been claimed', - [ErrorCode.REWARD_EXPIRED]: 'This reward has expired', - [ErrorCode.API_KEY_INVALID]: 'The API key is invalid', - [ErrorCode.API_KEY_EXPIRED]: 'The API key has expired', - [ErrorCode.API_KEY_REVOKED]: 'The API key has been revoked', - [ErrorCode.API_KEY_RATE_LIMITED]: 'API key rate limit exceeded', - [ErrorCode.INTERNAL_ERROR]: 'An unexpected internal error occurred', - [ErrorCode.SERVICE_UNAVAILABLE]: 'The service is temporarily unavailable', - [ErrorCode.INVALID_INPUT]: 'The input provided is invalid', - [ErrorCode.MISSING_PARAMETER]: 'A required parameter is missing', - [ErrorCode.INVALID_PARAMETER]: 'A parameter has an invalid value', - [ErrorCode.RESOURCE_CONFLICT]: 'The resource conflicts with another operation', - [ErrorCode.INSUFFICIENT_PERMISSIONS]: 'Insufficient permissions for this operation', - [ErrorCode.SESSION_EXPIRED]: 'Your session has expired', - [ErrorCode.ACCOUNT_LOCKED]: 'This account has been locked', - [ErrorCode.TOO_MANY_REQUESTS]: 'Too many requests, please try again later', - [ErrorCode.HTTP_EXCEPTION]: 'An HTTP error occurred', - [ErrorCode.HINT_NOT_FOUND]: 'No hints available for this challenge', - [ErrorCode.HINT_TIER_EXCEEDED]: 'You have not unlocked this hint tier yet', - [ErrorCode.HINT_CHALLENGE_INACCESSIBLE]: 'You do not have access to this challenge', - [ErrorCode.HINT_COOLDOWN_ACTIVE]: 'Please wait before requesting another hint', - [ErrorCode.HINT_MAX_VIEWS_EXCEEDED]: 'You have reached the maximum views for this hint', -} diff --git a/BackendAcademy/src/common/http-exception.filter.ts b/BackendAcademy/src/common/http-exception.filter.ts deleted file mode 100644 index 8b65d5615e..0000000000 --- a/BackendAcademy/src/common/http-exception.filter.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { - ArgumentsHost, - Catch, - ExceptionFilter, - HttpException, - HttpStatus, - } from '@nestjs/common'; - import { Request, Response } from 'express'; -import { MonitoringService } from '../monitoring/monitoring.service'; -import { ErrorCode, ERROR_CODE_MESSAGES } from '../common/error-codes.constants'; - - @Catch() - export class HttpExceptionFilter implements ExceptionFilter { - constructor(private readonly monitoring: MonitoringService) {} - - catch(exception: unknown, host: ArgumentsHost): void { - const ctx = host.switchToHttp(); - const response = ctx.getResponse(); - const request = ctx.getRequest(); - - const isHttpException = exception instanceof HttpException; - const statusCode = isHttpException - ? exception.getStatus() - : HttpStatus.INTERNAL_SERVER_ERROR; - - const rawBody = isHttpException ? exception.getResponse() : null; - - const { errorCode, message } = this.normalize(rawBody, exception); - this.monitoring.recordError( - request.route?.path ?? request.url, - errorCode, - ); - - response.status(statusCode).json({ - error: errorCode, - message, - statusCode, - path: request.url, - timestamp: new Date().toISOString(), - }); - } - - private normalize( - rawBody: unknown, - exception: unknown, - ): { errorCode: string; message: string } { - if ( - rawBody && - typeof rawBody === 'object' && - 'error' in rawBody && - 'message' in rawBody - ) { - return { - errorCode: String((rawBody as any).error), - message: String((rawBody as any).message), - }; - } - - if (typeof rawBody === 'string') { - return { errorCode: ErrorCode.HTTP_EXCEPTION, message: rawBody }; - } - - return { - errorCode: ErrorCode.INTERNAL_ERROR, - message: - exception instanceof Error - ? ERROR_CODE_MESSAGES[ErrorCode.INTERNAL_ERROR] - : ERROR_CODE_MESSAGES[ErrorCode.INTERNAL_ERROR], - }; - } - } \ No newline at end of file diff --git a/BackendAcademy/src/common/response.interceptor.ts b/BackendAcademy/src/common/response.interceptor.ts deleted file mode 100644 index b0d05ad6d3..0000000000 --- a/BackendAcademy/src/common/response.interceptor.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs-common'; -import { Observable } from 'rxjs'; -import { map } from 'rxjs/operators'; - -export interface ResponseEnvelope { - success: boolean; - code: string; - data: T; -} - -@Injectable()\nexport class ResponseInterceptor implements NestInterceptor> { - intercept(context: ExecutionContext, next: CallHandler): Observable> { - return next.handle().pipe( - map((rawData) => { - // If the service already returned an envelope, unwrap it for consistency - let data = rawData; - if (this.isEnvelope(rawData)) { - data = (rawData as any).data; - } - return { - success: true, - code: 'OK', - data, - }; - }), - ); - } - - private isEnvelope(value: any): boolean { - return value !== null && typeof value === 'object' && 'success' in value && 'data' in value; - } -} diff --git a/BackendAcademy/src/common/transaction-manager.service.ts b/BackendAcademy/src/common/transaction-manager.service.ts deleted file mode 100644 index 51d5c79589..0000000000 --- a/BackendAcademy/src/common/transaction-manager.service.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; - -/** - * Represents a snapshot of state that can be restored on rollback. - * Each service that participates in a transaction provides its own - * snapshot type through a generic parameter. - */ -export interface TransactionSnapshot { - restore(): void; - data: T; -} - -/** - * A single operation within a transaction context. Returns a rollback - * snapshot so the transaction can undo the operation on failure. - */ -export type TransactionOperation = () => Promise; - -/** - * Result returned after executing an atomic operation group. - */ -export interface AtomicResult { - success: boolean; - result?: T; - error?: Error; -} - -/** - * In-memory transaction coordinator for services that rely on - * non-persistent stores (Maps, arrays). Provides a lightweight - * mechanism to group multiple state-mutating operations into a - * single atomic unit: either ALL side-effects apply or NONE do. - * - * ## How it works - * - * Each operation inside `runAtomic` returns a rollback function - * (a {@link TransactionSnapshot}). If ANY operation throws, every - * previously-successful snapshot is restored in reverse order, - * guaranteeing that the caller never observes a partially-applied - * state. - * - * ## Usage - * - * ```ts - * const result = await this.transactionManager.runAtomic(async (tx) => { - * const snap1 = tx.addOperation(() => this.progress.recordLesson(...)); - * const snap2 = tx.addOperation(() => this.analytics.trackEvent(...)); - * // If snap2 throws, snap1 is automatically rolled back. - * }); - * ``` - * - * ## Limitations - * - * This is an *application-level* transaction — it does NOT lock - * underlying data structures. Concurrent callers can still observe - * transient intermediate states. For true isolation use database - * transactions (TypeORM QueryRunner). This utility is the correct - * choice when the backing store is in-memory and DB-level isolation - * is not available. - */ -@Injectable() -export class TransactionManagerService { - private readonly logger = new Logger(TransactionManagerService.name); - - /** - * Execute a group of operations atomically. - * - * @param fn Receives a transaction context that can accumulate - * operations via `addOperation`. - * @returns The aggregated result of the last operation, or an - * error if any operation failed. - */ - async runAtomic( - fn: (ctx: TransactionContext) => Promise, - ): Promise> { - const ctx = new TransactionContext(); - - try { - const result = await fn(ctx); - return { success: true, result }; - } catch (error) { - const err = error instanceof Error ? error : new Error(String(error)); - - // Rollback in reverse order — each snapshot knows how to undo - // exactly one operation. - const snapshots = ctx.getSnapshots(); - for (let i = snapshots.length - 1; i >= 0; i--) { - try { - snapshots[i].restore(); - } catch (rollbackError) { - this.logger.error( - `Rollback failed for operation ${i}: ${rollbackError}`, - ); - } - } - - this.logger.warn( - `Transaction rolled back (${snapshots.length} operation(s)): ${err.message}`, - ); - - return { success: false, error: err }; - } - } -} - -/** - * Context object passed to the transaction body. Operations are - * registered via `addOperation` and their snapshots are stored - * for potential rollback. - */ -export class TransactionContext { - private readonly snapshots: TransactionSnapshot[] = []; - - /** - * Register an operation. The operation is executed immediately - * and its rollback snapshot is recorded. If the operation itself - * throws, the snapshot list is still updated so the caller can - * decide what to do (the error propagates to `runAtomic`). - */ - async addOperation( - operation: () => Promise, - ): Promise { - const snapshot = await operation(); - this.snapshots.push(snapshot); - return snapshot; - } - - getSnapshots(): TransactionSnapshot[] { - return this.snapshots; - } -} diff --git a/BackendAcademy/src/common/typed-dto.ts b/BackendAcademy/src/common/typed-dto.ts deleted file mode 100644 index b51af130a3..0000000000 --- a/BackendAcademy/src/common/typed-dto.ts +++ /dev/null @@ -1,198 +0,0 @@ -/** - * Typed DTO mappings for user and session data. - * - * #353: Replaces loosely typed maps with strongly typed DTOs - * so downstream errors are caught at compile time rather than - * at runtime during serialization or API responses. - */ - -// ── User DTOs ──────────────────────────────────────────────────────────── - -export interface UserRecordDto { - readonly id: string; - readonly email: string; - readonly displayName: string; - readonly bio: string; - readonly role: UserRole; - readonly avatarUrl: string | null; - readonly createdAt: Date; - readonly updatedAt: Date; -} - -export type UserRole = 'learner' | 'tutor' | 'admin'; - -export interface CreateUserDto { - readonly id: string; - readonly email: string; - readonly displayName: string; - readonly role?: UserRole; -} - -export interface UserPreferencesDto { - readonly learnerPreferences?: Record; - readonly tutorPreferences?: Record; -} - -export interface UserProfileSnapshot { - readonly userId: string; - readonly displayName: string; - readonly email: string; - readonly bio: string; - readonly avatarUrl: string | null; - readonly role: UserRole; - readonly preferences: UserPreferencesDto; - readonly lastUpdatedAt: Date; -} - -// ── Session DTOs ───────────────────────────────────────────────────────── - -export interface SessionRecordDto { - readonly sessionId: string; - readonly userId: string; - readonly role: UserRole; - readonly accessToken: string; - readonly refreshToken: string; - readonly deviceHash: string | null; - readonly isTrustedDevice: boolean; - readonly createdAt: Date; - readonly expiresAt: Date; - readonly revoked: boolean; -} - -export interface SessionSummaryDto { - readonly sessionId: string; - readonly userId: string; - readonly role: UserRole; - readonly deviceHash: string | null; - readonly isTrustedDevice: boolean; - readonly createdAt: Date; - readonly expiresAt: Date; -} - -// ── Enrollment DTOs ────────────────────────────────────────────────────── - -export interface EnrollmentRecordDto { - readonly id: string; - readonly userId: string; - readonly courseId: string; - readonly enrolledAt: Date; - readonly status: EnrollmentStatus; - readonly completedAt: Date | null; - readonly progressPercent: number; -} - -export type EnrollmentStatus = 'active' | 'completed' | 'dropped'; - -export interface CreateEnrollmentDto { - readonly userId: string; - readonly courseId: string; -} - -// ── Onboarding DTOs ────────────────────────────────────────────────────── - -export interface OnboardingCheckpointDto { - readonly stepName: string; - readonly completedAt: Date; - readonly metadata?: Record; -} - -export interface OnboardingProgressSnapshot { - readonly id: string; - readonly userId: string; - readonly currentStep: string; - readonly completedSteps: string[]; - readonly checkpoints: OnboardingCheckpointDto[]; - readonly totalSteps: number; - readonly isComplete: boolean; - readonly completionPercent: number; - readonly createdAt: Date; - readonly updatedAt: Date; -} - -// ── Analytics DTOs ─────────────────────────────────────────────────────── - -export interface AnalyticsEventDto { - readonly id: string; - readonly userId: string | null; - readonly eventType: string; - readonly properties: Record; - readonly timestamp: Date; - readonly sessionId?: string; -} - -// ── Mapping helpers ────────────────────────────────────────────────────── - -/** - * Maps a raw user record (from storage) to a UserRecordDto. - * Ensures all fields are present and correctly typed. - */ -export function toUserRecordDto(raw: Record): UserRecordDto { - return { - id: String(raw.id), - email: String(raw.email), - displayName: String(raw.displayName ?? raw.display_name ?? ''), - bio: String(raw.bio ?? ''), - role: (raw.role as UserRole) ?? 'learner', - avatarUrl: raw.avatarUrl != null ? String(raw.avatarUrl) : null, - createdAt: raw.createdAt instanceof Date ? raw.createdAt : new Date(String(raw.createdAt)), - updatedAt: raw.updatedAt instanceof Date ? raw.updatedAt : new Date(String(raw.updatedAt)), - }; -} - -/** - * Maps a raw session record to a SessionSummaryDto (without secrets). - */ -export function toSessionSummaryDto(raw: Record): SessionSummaryDto { - return { - sessionId: String(raw.sessionId ?? raw.session_id), - userId: String(raw.userId ?? raw.user_id), - role: (raw.role as UserRole) ?? 'learner', - deviceHash: raw.deviceHash != null ? String(raw.deviceHash) : null, - isTrustedDevice: Boolean(raw.isTrustedDevice), - createdAt: raw.createdAt instanceof Date ? raw.createdAt : new Date(String(raw.createdAt)), - expiresAt: raw.expiresAt instanceof Date ? raw.expiresAt : new Date(String(raw.expiresAt)), - }; -} - -/** - * Maps a raw enrollment record to an EnrollmentRecordDto. - */ -export function toEnrollmentRecordDto(raw: Record): EnrollmentRecordDto { - return { - id: String(raw.id), - userId: String(raw.userId ?? raw.user_id), - courseId: String(raw.courseId ?? raw.course_id), - enrolledAt: raw.enrolledAt instanceof Date ? raw.enrolledAt : new Date(String(raw.enrolledAt)), - status: (raw.status as EnrollmentStatus) ?? 'active', - completedAt: raw.completedAt instanceof Date ? raw.completedAt : raw.completedAt ? new Date(String(raw.completedAt)) : null, - progressPercent: Number(raw.progressPercent ?? 0), - }; -} - -/** - * Maps raw onboarding data to a progress snapshot with checkpoints. - */ -export function toOnboardingProgressSnapshot(raw: Record): OnboardingProgressSnapshot { - const completedSteps = Array.isArray(raw.completedSteps) ? raw.completedSteps as string[] : []; - const totalSteps = Number(raw.totalSteps ?? 0); - const checkpoints: OnboardingCheckpointDto[] = Array.isArray(raw.checkpoints) - ? (raw.checkpoints as Record[]).map((c) => ({ - stepName: String(c.stepName), - completedAt: c.completedAt instanceof Date ? c.completedAt : new Date(String(c.completedAt)), - metadata: (c.metadata as Record) ?? {}, - })) - : []; - - return { - id: String(raw.id), - userId: String(raw.userId), - currentStep: String(raw.currentStep ?? ''), - completedSteps, - checkpoints, - totalSteps, - isComplete: Boolean(raw.isComplete), - completionPercent: totalSteps > 0 ? Math.round((completedSteps.length / totalSteps) * 100) : 0, - createdAt: raw.createdAt instanceof Date ? raw.createdAt : new Date(String(raw.createdAt)), - updatedAt: raw.updatedAt instanceof Date ? raw.updatedAt : new Date(String(raw.updatedAt)), - }; -} diff --git a/BackendAcademy/src/common/validation.middleware.ts b/BackendAcademy/src/common/validation.middleware.ts deleted file mode 100644 index e3394b962b..0000000000 --- a/BackendAcademy/src/common/validation.middleware.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { - PipeTransform, - Injectable, - BadRequestException, - ArgumentMetadata, -} from '@nestjs/common'; -import { ErrorCode } from './error-codes.constants'; - -export interface ValidationSchema { - required?: string[]; - optional?: string[]; - optionalArrays?: string[]; - properties?: Record; -} - -@Injectable() -export class ValidationPipe implements PipeTransform { - private readonly schema: ValidationSchema; - - constructor(schema: ValidationSchema) { - this.schema = schema; - } - - transform(value: any, metadata: ArgumentMetadata): any { - if (!value || typeof value !== 'object') { - throw new BadRequestException({ - error: ErrorCode.VALIDATION_ERROR, - message: 'Request body must be a JSON object', - }); - throw new BadRequestException('Request body must be a JSON object'); - } - - if (this.schema.required) { - for (const field of this.schema.required) { - if (value[field] === undefined || value[field] === null) { - throw new BadRequestException({ - error: ErrorCode.MISSING_PARAMETER, - message: `Missing required field: ${field}`, - }); - throw new BadRequestException(`Missing required field: ${field}`); - } - } - } - - if (this.schema.properties) { - for (const [field, rules] of Object.entries(this.schema.properties)) { - const fieldValue = value[field]; - if (fieldValue !== undefined && fieldValue !== null) { - if (rules.type === 'string' && typeof fieldValue !== 'string') { - throw new BadRequestException({ - error: ErrorCode.INVALID_PARAMETER, - message: `Field "${field}" must be a string`, - }); - } - if (rules.type === 'number' && typeof fieldValue !== 'number') { - throw new BadRequestException({ - error: ErrorCode.INVALID_PARAMETER, - message: `Field "${field}" must be a number`, - }); - } - if (rules.type === 'object' && (typeof fieldValue !== 'object' || Array.isArray(fieldValue))) { - throw new BadRequestException({ - error: ErrorCode.INVALID_PARAMETER, - message: `Field "${field}" must be an object`, - }); - } - if (rules.type === 'array' && !Array.isArray(fieldValue)) { - throw new BadRequestException({ - error: ErrorCode.INVALID_PARAMETER, - message: `Field "${field}" must be an array`, - }); - } - if (rules.enum && !rules.enum.includes(fieldValue)) { - throw new BadRequestException({ - error: ErrorCode.INVALID_PARAMETER, - message: `Field "${field}" must be one of: ${rules.enum.join(', ')}`, - }); - throw new BadRequestException(`Field "${field}" must be a string`); - } - if (rules.type === 'number' && typeof fieldValue !== 'number') { - throw new BadRequestException(`Field "${field}" must be a number`); - } - if (rules.type === 'object' && (typeof fieldValue !== 'object' || Array.isArray(fieldValue))) { - throw new BadRequestException(`Field "${field}" must be an object`); - } - if (rules.type === 'array' && !Array.isArray(fieldValue)) { - throw new BadRequestException(`Field "${field}" must be an array`); - } - if (rules.enum && !rules.enum.includes(fieldValue)) { - throw new BadRequestException( - `Field "${field}" must be one of: ${rules.enum.join(', ')}`, - ); - } - } - } - } - - return value; - } -} \ No newline at end of file diff --git a/BackendAcademy/src/common/validation.pipe.ts b/BackendAcademy/src/common/validation.pipe.ts deleted file mode 100644 index f426d7f26c..0000000000 --- a/BackendAcademy/src/common/validation.pipe.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { ValidationPipe, ValidationPipeOptions } from '@nestjs/common'; - -/** - * Single source of truth for request-payload validation. - * - * Every deployment mode (local, container, e2e tests) must use these exact - * options so that malformed payloads — including nested DTOs and arrays — - * are rejected identically across all controllers. - * - * - `whitelist` / `forbidNonWhitelisted`: unknown top-level properties are - * rejected with 400 instead of being silently stripped or persisted. - * - `forbidUnknownValues`: objects that carry no validation metadata are - * rejected. This surfaces DTOs that forgot their class-validator - * decorators instead of letting them silently accept anything. - * - `transform`: incoming plain objects are converted to DTO instances so - * `@Type(() => …)` / `@ValidateNested()` run on nested payloads. - * - `enableImplicitConversion: false`: coercion must be explicit via - * `@Type(() => Number)` etc., so query-string parsing behaves the same - * as JSON body parsing. - */ -export const VALIDATION_PIPE_OPTIONS: ValidationPipeOptions = { - whitelist: true, - forbidNonWhitelisted: true, - forbidUnknownValues: true, - transform: true, - transformOptions: { enableImplicitConversion: false }, -}; - -/** Builds the app-wide validation pipe. Used by `main.ts` and e2e tests. */ -export function createValidationPipe(): ValidationPipe { - return new ValidationPipe(VALIDATION_PIPE_OPTIONS); -} diff --git a/BackendAcademy/src/config/config.module.spec.ts b/BackendAcademy/src/config/config.module.spec.ts deleted file mode 100644 index a8a1a6d564..0000000000 --- a/BackendAcademy/src/config/config.module.spec.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { Test } from '@nestjs/testing'; -import { ConfigModule as NestConfigModule, ConfigService } from '@nestjs/config'; - -import { AppConfigModule, validateEnvironment } from './config.module'; -import { - ENV_VALIDATION_OPTIONS, - MIN_PRODUCTION_SECRET_LENGTH, - NON_PRODUCTION_DEFAULTS, - envValidationSchema, -} from './env.schema'; - -/** - * These tests exercise the module wiring rather than the schema rules: the - * module must hand `forRoot()` exactly one composed schema plus the shared - * validation options, and a broken environment must abort the boot with a - * deterministic, secret-free error. - */ -describe('AppConfigModule', () => { - const envSnapshot = { ...process.env }; - - beforeEach(() => { - // Nest's ConfigModule validates env only once per process (static cache). - // Reset the registry so every test re-runs forRoot validation from a - // clean slate and we can assert boot-time rejection deterministically. - jest.resetModules(); - }); - - afterEach(() => { - for (const key of Object.keys(process.env)) { - if (!(key in envSnapshot)) { - delete process.env[key]; - } - } - Object.assign(process.env, envSnapshot); - jest.restoreAllMocks(); - }); - - it('forwards the single composed schema and shared options to forRoot', () => { - // The module declares exactly one schema and one options object and runs - // both through `validateEnvironment`, which is what forRoot invokes at boot. - expect(envValidationSchema).toBeDefined(); - expect(ENV_VALIDATION_OPTIONS).toMatchObject({ - abortEarly: false, - convert: true, - allowUnknown: true, - stripUnknown: false, - }); - expect(typeof validateEnvironment).toBe('function'); - - // Development (the default) validates and applies defaults without throwing. - expect(() => validateEnvironment({ NODE_ENV: 'development' })).not.toThrow(); - }); - - - it('exposes validated and defaulted values through ConfigService', async () => { - process.env.NODE_ENV = 'test'; - delete process.env.PORT; - delete process.env.DATABASE_URL; - - const moduleRef = await Test.createTestingModule({ - imports: [AppConfigModule], - }).compile(); - - const config = moduleRef.get(ConfigService); - - expect(config.get('PORT')).toBe(3000); - expect(config.get('DATABASE_URL')).toBe( - NON_PRODUCTION_DEFAULTS.test.DATABASE_URL, - ); - }); - - it('fails the boot deterministically when production config is missing', () => { - expect(() => - validateEnvironment({ - NODE_ENV: 'production', - }), - ).toThrow(/Config validation error/); - expect(() => - validateEnvironment({ NODE_ENV: 'production' }), - ).toThrow(/DATABASE_URL/); - expect(() => - validateEnvironment({ NODE_ENV: 'production' }), - ).toThrow(/JWT_SECRET/); - }); - - it('does not print secret values when a production secret is rejected', () => { - const leaked = 'leaked-jwt-secret-value'; - let message = ''; - try { - validateEnvironment({ - NODE_ENV: 'production', - DATABASE_URL: 'postgresql://app:pw@db.internal:5432/rustacademy', - REDIS_HOST: 'redis.internal', - JWT_SECRET: leaked, - ASSET_SIGNING_SECRET: 'c'.repeat( - MIN_PRODUCTION_SECRET_LENGTH + 4, - ), - }); - } catch (err) { - message = err instanceof Error ? err.message : String(err); - } - - expect(message).toContain('JWT_SECRET'); - expect(message).not.toContain(leaked); - }); -}); diff --git a/BackendAcademy/src/config/config.module.ts b/BackendAcademy/src/config/config.module.ts deleted file mode 100644 index a551b992f2..0000000000 --- a/BackendAcademy/src/config/config.module.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ConfigModule as NestConfigModule } from '@nestjs/config'; -import * as Joi from 'joi'; - -import { ENV_VALIDATION_OPTIONS, envValidationSchema } from './env.schema'; - -/** - * Joi validation options used for environment schema validation. - * - * `convert: true` is essential for this application: because environment - * variables are always strings when read from `process.env` or `.env` files, - * they must be coerced to `number`/`boolean` before Joi can apply range - * checks (e.g. port bounds, TTL limits). Without this flag a value like - * `PoRT="70000"` would never be range-checked numerically and could slip - * past the schema. Spread the canonical options from `env.schema.`ts` and - * override convert to guarantee coercion even if the upstream constant is - * changed. - */ -const validationOptions = { - ...ENV_VALIDATION_OPTIONS, - convert: true, -}; - -// Secrets that are considered unsafe for production. -const UNSAFE_DEFAUlt_JWT_SECRETS = ['changeme']; - -/** - * Extends the base environment schema with extra constraints. - * - * BA-025: TypeORM synchronize is disabled in production-like environments. - * The schema rejects `TYPEORM_SYNCHRONIZE=true` when `NODE_ENV` is - * `production` or `staging`. This prevents accidental schema mutations. - */ -const composedValidationSchema = envValidationSchema.append({ - NODE_ENV: Joi.string() - .valid('development', 'production', 'test', 'staging') - .default('development'), - TYPEORM_SYNCHRONIZE: Joi.boolean() - .default(false) - .when('NODE_ENV', { - is: Joi.valid('production', 'staging'), - then: Joi.boolean() - .valid(false) - .default(false) - .messages({ - 'any.only': - 'TypeORM synchronize must be false in production/staging', - }), - otherwise: Joi.boolean(), - }), -}); - -/** - * Validate an environment snapshot against the composed schema using the - * selected options. This is the exact routine `ConfigModule.forRoot()` runs - * at boot, exposed as a plain function so it can be invoked (and inspected) - * without Nest's process-wide static validation cache getting in the way. - */ -export function validateEnvironment( - env: Record, -): Record { - const { error, value } = composedValidationSchema.validate( - env, - validationOptions, - ); - if (error) { - throw new Error(`Config validation error: ${error.message}`); - } - const validated = value as Record; - const nodeEnv = validated.NODE_ENV; - const wwtSecret = validated.JWT_SECRET; - const synchronize = validated.TYPEORM_SYNCHRONIZE; - - if ((nodeEnv === 'production' || nodeEnv === 'staging') && synchronize) { - throw new Error( - 'TypeORM synchronize is not allowed in production or staging', - ); - } - - if ( - nodeEnv === 'production' && - (!jwtSecret || UNSAFE_DEFAUlt_JWT_SECRETT.includes(jwtSecret as string)) - ) { - throw new Error( - 'JWT_SECRET must be configured with a strong value in production', - ); - } - - return validated; -} - -/** - * Application configuration module. - * - * Exactly one composed schema ({@link composedValidationSchema}) and exactly - * one set of validation options ({@link validationOptions}) are handed to - * `ConfigModule.forRoot()`. We pass a validate callback so the composed - * schema is guaranteed to run on every `forRoot()` call. Startup fails - * deterministically: `abortEarly: false` reports every invalid variable in - * one pass, and secret-bearing keys use value-free error messages so a - * failed boot never prints a credential. - */ -@Module({ - imports: [ - NestConfigModule.forRoot({ - isGlobal: true, - validationSchema: composedValidationSchema, - cache: true, - envFilePath: ['.env.local', '.env'], - expandVariables: true, - validationOptions, - validate: validateEnvironment, - }), - ], - exports: [NestConfigModule], -}) -export class AppConfigModule {} \ No newline at end of file diff --git a/BackendAcademy/src/config/env.schema.ts b/BackendAcademy/src/config/env.schema.ts deleted file mode 100644 index 6ac8642353..0000000000 --- a/BackendAcademy/src/config/env.schema.ts +++ /dev/null @@ -1,678 +0,0 @@ -import * as Joi from 'joi'; - -/** - * Environment-variable contract for the BackendAcademy service. - * - * The file exposes one schema per functional area plus a single composed - * schema ({@link envValidationSchema}) which is the *only* schema handed to - * `ConfigModule.forRoot()`. Keeping the composition here — instead of - * assembling it inline in `config.module.ts` — guarantees that startup - * validation, unit tests and tooling all agree on the same rules. - * - * Two invariants are enforced throughout: - * - * 1. **Production is strict.** Secrets and persistence settings are - * mandatory when `NODE_ENV=production`; there are no silent fallbacks - * that would let the service boot half-configured. - * 2. **Errors never echo secrets.** Every secret-bearing key overrides its - * Joi messages with value-free templates, so a bad `JWT_SECRET` produces - * `"JWT_SECRET" ...` and never the secret itself. - */ - -/** Runtime environments understood by the application. */ -export const NODE_ENVIRONMENTS = ['development', 'production', 'staging', 'test'] as const; - -export type NodeEnvironment = (typeof NODE_ENVIRONMENTS)[number]; - -/** - * Keys whose values must never be rendered into validation errors, logs or - * crash reports. Consumers (and tests) can use this list to assert that - * output is scrubbed. - */ -export const SECRET_ENV_KEYS = [ - 'DATABASE_URL', - 'REDIS_PASSWORD', - 'JWT_SECRET', - 'ASSET_SIGNING_SECRET', - 'API_KEY_SECRET', - 'ANTHROPIC_API_KEY', - 'OPENAI_API_KEY', -] as const; - -export type SecretEnvKey = (typeof SECRET_ENV_KEYS)[number]; - -/** Minimum entropy (in characters) demanded of a production secret. */ -export const MIN_PRODUCTION_SECRET_LENGTH = 32; - -/** - * Well-known placeholder secrets shipped in `.env.example` and in the - * development defaults below. They are explicitly rejected in production so - * a copy-pasted example file cannot become a live signing key. - */ -export const FORBIDDEN_PRODUCTION_SECRETS = [ - 'change_me_in_production', - 'change-me-in-production', - 'changeme', - 'change-me', - 'placeholder', - 'secret', - 'password', -] as const; - -/** - * Explicit non-production defaults. They are intentionally verbose and - * self-describing: a value that leaks into a production incident report is - * immediately recognisable as a local placeholder, and every one of them is - * listed in {@link FORBIDDEN_PRODUCTION_SECRETS} handling below. - */ -export const NON_PRODUCTION_DEFAULTS = { - development: { - DATABASE_URL: 'postgresql://postgres:postgres@localhost:5432/rustacademy_development', - JWT_SECRET: 'development-only-insecure-jwt-secret-change-me', - ASSET_SIGNING_SECRET: 'development-only-insecure-asset-signing-secret', - }, - test: { - DATABASE_URL: 'postgresql://postgres:postgres@localhost:5432/rustacademy_test', - JWT_SECRET: 'test-only-insecure-jwt-secret-change-me-please', - ASSET_SIGNING_SECRET: 'test-only-insecure-asset-signing-secret-please', - }, -} as const; - -/** - * Message templates that never interpolate `{{#value}}` / `{[.]}`. - * - * Joi's stock templates for `string.pattern.base` (and a few others) embed - * the offending value in the message. For secrets that would print the - * credential straight to stderr during a failed boot, so every secret key is - * built through {@link secretString} which installs these overrides. - */ -const SECRET_SAFE_MESSAGES: Record = { - 'any.required': '{{#label}} is required but was not provided', - 'any.invalid': '{{#label}} uses a forbidden placeholder value', - 'any.only': '{{#label}} is not one of the accepted values', - 'string.base': '{{#label}} must be a string', - 'string.empty': '{{#label}} must not be empty', - 'string.min': '{{#label}} must be at least {{#limit}} characters long', - 'string.max': '{{#label}} must be at most {{#limit}} characters long', - 'string.pattern.base': '{{#label}} does not match the required format', - 'string.pattern.name': '{{#label}} does not match the required format', - 'string.uri': '{{#label}} must be a valid URI', - 'string.uriCustomScheme': '{{#label}} must be a valid URI using one of the accepted schemes', - 'string.trim': '{{#label}} must not contain leading or trailing whitespace', -}; - -/** - * Base builder for a secret-bearing string. Guarantees the value is never - * echoed back through a validation message. - */ -function secretString(): Joi.StringSchema { - return Joi.string().trim().messages(SECRET_SAFE_MESSAGES); -} - -/** - * Optional free-text variable. - * - * `dotenv` turns a bare `KEY=` line into an empty string, and `.env.example` - * ships several of those. Empty is therefore treated as "not configured" - * instead of a hard failure. - */ -function optionalString(): Joi.StringSchema { - return Joi.string().allow('').optional(); -} - -/** - * Applies a different rule set per runtime environment. - * - * `NODE_ENV` carries a default, and Joi resolves sibling references *after* - * defaults are applied, so omitting `NODE_ENV` correctly selects the - * development branch. - */ -function perEnvironment( - base: Joi.StringSchema, - branches: { - production: Joi.Schema; - test: Joi.Schema; - development: Joi.Schema; - }, -): Joi.StringSchema { - return base.when('NODE_ENV', { - switch: [ - { is: 'production', then: branches.production }, - { is: 'staging', then: branches.production }, - { is: 'test', then: branches.test }, - ], - otherwise: branches.development, - }); -} - -/** A secret that must be supplied — with real entropy — in production. */ -function productionSecret(defaults: { - development: string; - test: string; -}): Joi.StringSchema { - return perEnvironment(secretString(), { - production: Joi.string() - .min(MIN_PRODUCTION_SECRET_LENGTH) - .invalid( - ...FORBIDDEN_PRODUCTION_SECRETS, - defaults.development, - defaults.test, - ) - .required(), - test: Joi.string().default(defaults.test), - development: Joi.string().default(defaults.development), - }); -} - -/** - * Parses `CORS_ORIGIN` into the shape `main.ts` expects: either the literal - * wildcard or a list of concrete origins. - */ -function parseCorsOrigin( - value: string, - helpers: Joi.CustomHelpers, -): string | string[] | Joi.ErrorReport { - const trimmed = value.trim(); - if (trimmed === '*') { - return '*'; - } - - const origins = trimmed - .split(',') - .map((origin) => origin.trim()) - .filter(Boolean); - - if (origins.length === 0) { - return helpers.error('any.invalid'); - } - - return origins.length === 1 ? origins[0] : origins; -} - -// ───────────────────────────────────────────────────────────────────────────── -// Base runtime, persistence, auth and AI configuration -// ───────────────────────────────────────────────────────────────────────────── - -export const baseEnvSchema = Joi.object({ - NODE_ENV: Joi.string() - .valid(...NODE_ENVIRONMENTS) - .default('development') - .description('Runtime environment for the application'), - - PORT: Joi.number() - .port() - .default(3000) - .description('Port number for the HTTP server'), - - CORS_ORIGIN: Joi.string() - .default('*') - .custom(parseCorsOrigin, 'CORS origin list') - .description( - 'Allowed CORS origin. Either "*" or a comma-separated list of origins, ' + - 'parsed here so main.ts receives a ready-to-use value.', - ), - - LOCALE: Joi.string().default('en').description('Default localization locale'), - - // ── Persistence ─────────────────────────────────────────────── - DATABASE_URL: perEnvironment( - secretString().uri({ - scheme: ['postgres', 'postgresql', 'mysql', 'sqlite', 'file'], - }), - { - production: Joi.string().required(), - test: Joi.string().default(NON_PRODUCTION_DEFAULTS.test.DATABASE_URL), - development: Joi.string().default( - NON_PRODUCTION_DEFAULTS.development.DATABASE_URL, - ), - }, - ).description( - 'Database connection URL. Mandatory in production — the service refuses ' + - 'to boot without persistence configured.', - ), - - DB_SYNCHRONIZE: Joi.boolean() - .when('NODE_ENV', { - is: 'development', - then: Joi.boolean().default(true), - otherwise: Joi.boolean().valid(false).default(false), - }) - .description( - 'TypeORM schema synchronization. Enabled only in development; forced ' + - 'to false in production-like environments — use migrations instead.', - ), - - REDIS_HOST: perEnvironment(Joi.string().hostname(), { - production: Joi.string().required(), - test: Joi.string().default('localhost'), - development: Joi.string().default('localhost'), - }).description( - 'Redis host used for caching and background jobs. Mandatory in ' + - 'production so a deployment never silently falls back to localhost.', - ), - - REDIS_PORT: Joi.number() - .port() - .default(6379) - .description('Redis port number'), - - REDIS_PASSWORD: secretString() - .allow('') - .optional() - .description('Optional Redis password'), - - // ── Authentication ──────────────────────────────────────────── - JWT_SECRET: productionSecret({ - development: NON_PRODUCTION_DEFAULTS.development.JWT_SECRET, - test: NON_PRODUCTION_DEFAULTS.test.JWT_SECRET, - }).description( - 'JWT signing secret. Mandatory in production and required to be at ' + - `least ${MIN_PRODUCTION_SECRET_LENGTH} characters long.`, - ), - - JWT_EXPIRES_IN: Joi.string() - .pattern(/^\d+[smhd]$/) - .default('1h') - .description('JWT lifetime, e.g. "30m", "1h", "7d".'), - - API_KEY_SECRET: perEnvironment(secretString().allow(''), { - production: Joi.string() - .invalid('', ...FORBIDDEN_PRODUCTION_SECRETS) - .min(MIN_PRODUCTION_SECRET_LENGTH), - test: Joi.string(), - development: Joi.string(), - }) - .optional() - .description( - 'Optional secret used to hash issued API keys. When present in ' + - 'production it must meet the same entropy bar as JWT_SECRET.', - ), - - // ── AI providers ────────────────────────────────────────────── - AI_PROVIDER: Joi.string() - .valid('claude', 'openai', 'mock') - .default('mock') - .description('Active AI provider implementation'), - - ANTHROPIC_API_KEY: secretString() - .allow('') - .optional() - .when('AI_PROVIDER', { - is: 'claude', - then: Joi.string().invalid('').required(), - }) - .description('Anthropic API key. Required when AI_PROVIDER=claude.'), - - OPENAI_API_KEY: secretString() - .allow('') - .optional() - .when('AI_PROVIDER', { - is: 'openai', - then: Joi.string().invalid('').required(), - }) - .description('OpenAI API key. Required when AI_PROVIDER=openai.'), - - AI_MODEL: optionalString().description('AI model override'), - - AI_MAX_TOKENS: Joi.number() - .integer() - .min(1) - .max(200_000) - .default(4096) - .description('Maximum tokens per AI request'), - - AI_TEMPERATURE: Joi.number() - .min(0) - .max(2) - .default(0.7) - .description('Sampling temperature for AI responses'), - - // ── BA-078: AI provider retry policy ──────────────────────── - /** Maximum retry attempts for transient AI provider failures (429/5xx). */ - AI_RETRY_MAX_ATTEMPTS: Joi.number() - .integer() - .min(0) - .max(10) - .default(3) - .description('Maximum retry attempts after a transient AI provider failure.'), - - /** Base backoff delay in ms before the first AI provider retry. */ - AI_RETRY_BASE_DELAY_MS: Joi.number() - .integer() - .min(0) - .max(60_000) - .default(250) - .description('Base exponential-backoff delay in ms for AI provider retries.'), - - /** Upper bound in ms for AI provider retry backoff. */ - AI_RETRY_MAX_DELAY_MS: Joi.number() - .integer() - .min(1) - .max(300_000) - .default(5_000) - .description('Maximum backoff delay in ms for AI provider retries.'), - - /** Version identifier for the active chat prompt template set (#374). */ - AI_PROMPT_TEMPLATE_VERSION: Joi.string() - .pattern(/^\d+\.\d+\.\d+$/) - .default('1.0.0') - .description( - 'Semantic version of the chat prompt template set. ' + - 'Changing this allows controlled rollout of new prompt designs.', - ), - - /** Maximum messages retained in chat history before summarisation (#372). */ - AI_MAX_CHAT_HISTORY_LENGTH: Joi.number() - .integer() - .min(10) - .max(500) - .default(50) - .description( - 'When chat history exceeds this length, older messages are ' + - 'compacted into a conversation summary to control token usage.', - ), - - /** Path to prompt template configuration file (#374). */ - AI_PROMPT_TEMPLATE_PATH: Joi.string() - .default('config/prompt-templates.json') - .description('Path to the versioned prompt template configuration file.'), - - // ── Cron schedules ──────────────────────────────────────────── - CRON_CLEANUP_SCHEDULE: Joi.string().default('0 0 * * *'), - CRON_ANALYTICS_SCHEDULE: Joi.string().default('0 */6 * * *'), - CRON_NOTIFICATIONS_SCHEDULE: Joi.string().default('*/30 * * * *'), - CRON_CONTRACT_REPLAY_SCHEDULE: optionalString(), -}); - -// ───────────────────────────────────────────────────────────────────────────── -// Asset storage & upload quotas (see AssetsService) -// ───────────────────────────────────────────────────────────────────────────── - -export const assetEnvSchema = Joi.object({ - ASSETS_UPLOAD_DIR: Joi.string() - .default('./data/uploads') - .description('Directory where uploaded assets are persisted on disk'), - - ASSETS_STATIC_DIR: Joi.string() - .default('./public') - .description('Read-only static asset directory served at /static'), - - ASSETS_BASE_URL: Joi.string() - .default('/api/v1/assets') - .description('Base URL advertised inside asset metadata'), - - ASSETS_MAX_SIZE_MB: Joi.number() - .greater(0) - .max(1024) - .default(10) - .description('Maximum size of a single uploaded asset, in megabytes'), - - ASSETS_MAX_TOTAL_MB: Joi.number() - .greater(0) - .default(1024) - .description('Aggregate byte quota across all stored assets, in megabytes'), - - ASSETS_MAX_COUNT: Joi.number() - .integer() - .min(1) - .default(10_000) - .description('Maximum number of assets retained by the registry'), - - ASSET_SIGNING_SECRET: productionSecret({ - development: NON_PRODUCTION_DEFAULTS.development.ASSET_SIGNING_SECRET, - test: NON_PRODUCTION_DEFAULTS.test.ASSET_SIGNING_SECRET, - }).description( - 'HMAC secret used to sign asset download URLs. Mandatory in production ' + - 'because an empty secret makes signed URLs forgeable.', - ), - - ASSET_SIGNED_URL_TTL_SECONDS: Joi.number() - .integer() - .min(60) - .max(86_400) - .default(3600) - .description('Default lifetime of a signed asset URL, in seconds'), -}); - -// ───────────────────────────────────────────────────────────────────────────── -// Contract ingestion / registry / replay (#393 – #396) -// ───────────────────────────────────────────────────────────────────────────── - -export const contractEnvSchema = Joi.object({ - CERTIFICATE_BASE_URL: Joi.string() - .uri() - .default('https://rustacademy.xyz/certificates') - .description( - 'Base URL used to construct shareable certificate verification links.', - ), - - CONTRACT_INGESTION_ENABLED: Joi.string() - .valid('true', 'false') - .default('false') - .description('Must be explicitly "true" to enable contract ingestion.'), - - CONTRACT_REGISTRY_REQUIRE_SCHEMA: Joi.string() - .valid('true', 'false') - .default('true') - .description( - 'When "true", contract registry entries must pass schema compatibility validation.', - ), - - CONTRACT_EVENT_REPLAY_ENABLED: Joi.string() - .valid('true', 'false') - .default('false') - .description('When "true", contract event replay endpoints are available.'), - - CONTRACT_ADAPTER_MODE: Joi.string() - .valid('native', 'stellar', 'mock') - .default('mock') - .description('Determines which contract adapter implementation is used.'), - - CONTRACT_NETWORK: Joi.string() - .valid('testnet', 'futurenet', 'mainnet') - .default('testnet') - .description('Stellar network target for contract deployments.'), - - STELLAR_HORIZON_URL: Joi.string() - .uri() - .allow('') - .optional() - .description('Stellar Horizon server URL for contract queries.'), - - CONTRACT_REGISTRY_MAX_ENTRIES: Joi.number() - .integer() - .min(1) - .default(1000) - .description('Maximum number of entries held in the contract registry.'), - - CONTRACT_SCHEMA_VERSION: Joi.string() - .pattern(/^\d+\.\d+\.\d+$/) - .default('1.0.0') - .description('Minimum required contract schema version.'), - - CONTRACT_REPLAY_MAX_EVENTS: Joi.number() - .integer() - .min(1) - .max(10_000) - .default(1000) - .description('Maximum number of events replayed in a single request.'), - - CONTRACT_EVENT_RETENTION_DAYS: Joi.number() - .integer() - .min(1) - .max(365) - .default(90) - .description('Number of days to retain contract event logs for replay.'), - - // ── Attachment scanning configuration — Issue #365 ──────────── - MAX_ATTACHMENT_SIZE_BYTES: Joi.number() - .integer() - .min(1) - .default(10_485_760) - .description('Maximum allowed attachment file size in bytes (default: 10 MB).'), - - ALLOWED_ATTACHMENT_TYPES: optionalString().description( - 'Comma-separated list of allowed MIME types for submission attachments. ' + - 'Example: "application/pdf,image/png,text/plain"', - ), - - ATTACHMENT_SCANNING_ENABLED: Joi.string() - .valid('true', 'false') - .default('true') - .description( - 'When "true", submission attachments are scanned for policy violations.', - ), - - // ── Readiness probe configuration — Issue #376 ──────────────── - READINESS_PROBE_TIMEOUT_MS: Joi.number() - .integer() - .min(100) - .default(5_000) - .description('Timeout for readiness probe dependency checks in milliseconds.'), - - // ── Task orchestrator configuration — Issue #364 ────────────── - TASK_ORCHESTRATOR_MAX_RETRIES: Joi.number() - .integer() - .min(0) - .default(3) - .description('Maximum number of retry attempts for scheduled tasks.'), - - TASK_ORCHESTRATOR_BASE_BACKOFF_MS: Joi.number() - .integer() - .min(100) - .default(1_000) - .description('Base backoff time in milliseconds before task retries.'), - - TASK_ORCHESTRATOR_MAX_BACKOFF_MS: Joi.number() - .integer() - .min(100) - .default(30_000) - .description('Maximum backoff time in milliseconds for task retries.'), -}); - -// ───────────────────────────────────────────────────────────────────────────── -// Background jobs & dead-letter queue -// ───────────────────────────────────────────────────────────────────────────── - -export const jobEnvSchema = Joi.object({ - MAX_JOB_RETRIES: Joi.number().integer().min(0).max(10).default(3), - JOB_RETRY_DELAY_MS: Joi.number().integer().min(100).default(5000), - DLQ_TTL_SECONDS: Joi.number().integer().min(60).default(604800), - EXPORT_NOTIFICATION_ENABLED: Joi.boolean().default(true), - EXPORT_RETRY_MAX: Joi.number().integer().min(0).max(10).default(3), - SIGNED_URL_TTL_SECONDS: Joi.number() - .integer() - .min(60) - .max(86400) - .default(3600), -}); - -// ───────────────────────────────────────────────────────────────────────────── -// Notification delivery and preferences (#385) -// ───────────────────────────────────────────────────────────────────────────── - -/** - * Environment variables for notification delivery and preferences (#385). - */ -export const notificationEnvSchema = Joi.object({ - /** When "true", user notification preferences are enforced before delivery */ - NOTIFICATION_ENFORCE_PREFERENCES: Joi.string() - .valid('true', 'false') - .default('true') - .description( - 'When "true", notification delivery checks user preferences first. ' + - 'Set to "false" to bypass preference checks for critical system alerts.', - ), - NOTIFICATION_DEFAULT_CHANNEL: Joi.string() - .valid('email', 'push', 'in-app', 'all') - .default('all') - .description( - 'Default notification channel for users without explicit preferences.', - ), -}); - -/** - * Environment variables for migration safety and ordering. - */ -export const migrationEnvSchema = Joi.object({ - MIGRATION_LOCK_TIMEOUT: Joi.number() - .integer() - .min(1000) - .default(300_000) - .description('Timeout in ms for acquiring a migration lock (default 5 min).'), - - MIGRATION_RETRY_ATTEMPTS: Joi.number() - .integer() - .min(0) - .max(10) - .default(3) - .description('Maximum retry attempts for failed migrations.'), - - MIGRATION_STRICT_ORDERING: Joi.string() - .valid('true', 'false') - .default('true') - .description( - 'When "true", migrations must be applied strictly in dependency order.', - ), - - MIGRATION_REQUIRE_PREFLIGHT: Joi.string() - .valid('true', 'false') - .default('true') - .description( - 'When "true", preflight validation must pass before migrations execute.', - ), -}); - -/** - * Environment variables for the Stellar network configuration (BA-090). - * - * These variables let the wallet service validate addresses, issuer, asset - * codes, network passphrase, and Horizon consistently with the environment - * the backend was started in. - */ -export const stellarEnvSchema = Joi.object({ - /** Stellar network the backend targets. */ - STELLAR_NETWORK: Joi.string() - .valid('testnet', 'futurenet', 'mainnet', 'public', 'custom') - .default('testnet') - .description('Stellar network target.'), - - /** Network passphrase that must match `STELLAR_NETWORK`. */ - STELLAR_NETWORK_PASSPHRASE: Joi.string() - .valid( - 'Test SDF Network ; September 2015', - 'Public Global Stellar Network ; September 2015', - 'Standalone Network ; February 2017', - ) - .default('Test SDF Network ; September 2015') - .description( - 'Stellar network passphrase. Must match the configured network.', - ), - - /** Horizon server URL used to query balances and validate transactions. */ - STELLAR_HORIZON_URL: Joi.string() - .uri() - .optional() - .description('Horizon server URL for Stellar queries.'), - - /** - * Comma-separated `assetCode:issuer` allow-list for transactions, - * e.g. `XLM:native,USDC:GCGAC2ZAAYZLT...`. Native XLM uses `native`. - */ - STELLAR_ALLOWED_ASSETS: Joi.string() - .optional() - .description('Allowed assetCode:issuer pairs (comma-separated).'), -}); - -/** - * Combined validation schema that includes base, contract, migration, - * notification, and stellar environment variables. - * Used by config.module.ts to validate all env vars at startup. - */ -export const envValidationSchema = baseEnvSchema - .concat(assetEnvSchema) - .concat(contractEnvSchema) - .concat(jobEnvSchema) - .concat(migrationEnvSchema) - .concat(notificationEnvSchema) - .concat(stellarEnvSchema); diff --git a/BackendAcademy/src/config/env.schema.unit.spec.ts b/BackendAcademy/src/config/env.schema.unit.spec.ts deleted file mode 100644 index ecfdfd1b07..0000000000 --- a/BackendAcademy/src/config/env.schema.unit.spec.ts +++ /dev/null @@ -1,374 +0,0 @@ -import * as Joi from 'joi'; - -import { - ENV_VALIDATION_OPTIONS, - FORBIDDEN_PRODUCTION_SECRETS, - MIN_PRODUCTION_SECRET_LENGTH, - NON_PRODUCTION_DEFAULTS, - SECRET_ENV_KEYS, - assetEnvSchema, - baseEnvSchema, - contractEnvSchema, - envValidationSchema, - isFeatureEnabled, - isFeatureExplicitlyDisabled, - jobEnvSchema, - migrationEnvSchema, - notificationEnvSchema, -} from './env.schema'; - -/** Validate through the exact options `ConfigModule.forRoot()` uses. */ -function validate(env: Record): Joi.ValidationResult { - return envValidationSchema.validate(env, ENV_VALIDATION_OPTIONS); -} - -const PRODUCTION_SECRET = 'a'.repeat(MIN_PRODUCTION_SECRET_LENGTH + 8); -const PRODUCTION_SIGNING_SECRET = 'b'.repeat(MIN_PRODUCTION_SECRET_LENGTH + 8); - -const validProductionEnv = { - NODE_ENV: 'production', - DATABASE_URL: 'postgresql://app:pw@db.internal:5432/rustacademy', - REDIS_HOST: 'redis.internal', - JWT_SECRET: PRODUCTION_SECRET, - ASSET_SIGNING_SECRET: PRODUCTION_SIGNING_SECRET, -}; - -describe('env.schema', () => { - describe('composition', () => { - it('is a single schema that contains every area-specific key', () => { - const keys = Object.keys(envValidationSchema.describe().keys ?? {}); - - const areaSchemas = [ - baseEnvSchema, - assetEnvSchema, - contractEnvSchema, - jobEnvSchema, - migrationEnvSchema, - notificationEnvSchema, - ]; - - for (const schema of areaSchemas) { - for (const key of Object.keys(schema.describe().keys ?? {})) { - expect(keys).toContain(key); - } - } - }); - - it('leaves unknown-variable handling to the shared validation options', () => { - // Schema-level `.unknown()` / `.options()` used to fight with the - // options object passed to forRoot. The schema must stay neutral. - const described = envValidationSchema.describe() as { - flags?: { unknown?: boolean }; - preferences?: Joi.ValidationOptions; - }; - - expect(described.flags?.unknown).toBeUndefined(); - expect(described.preferences).toBeUndefined(); - expect(ENV_VALIDATION_OPTIONS).toEqual({ - abortEarly: false, - convert: true, - allowUnknown: true, - stripUnknown: false, - }); - }); - }); - - describe('development and test defaults', () => { - it('boots with an empty environment and applies explicit defaults', () => { - const { error, value } = validate({}); - - expect(error).toBeUndefined(); - expect(value.NODE_ENV).toBe('development'); - expect(value.PORT).toBe(3000); - expect(value.CORS_ORIGIN).toBe('*'); - expect(value.LOCALE).toBe('en'); - expect(value.REDIS_HOST).toBe('localhost'); - expect(value.REDIS_PORT).toBe(6379); - expect(value.DATABASE_URL).toBe( - NON_PRODUCTION_DEFAULTS.development.DATABASE_URL, - ); - expect(value.JWT_SECRET).toBe( - NON_PRODUCTION_DEFAULTS.development.JWT_SECRET, - ); - expect(value.ASSET_SIGNING_SECRET).toBe( - NON_PRODUCTION_DEFAULTS.development.ASSET_SIGNING_SECRET, - ); - }); - - it('applies the dedicated test defaults when NODE_ENV=test', () => { - const { error, value } = validate({ NODE_ENV: 'test' }); - - expect(error).toBeUndefined(); - expect(value.DATABASE_URL).toBe(NON_PRODUCTION_DEFAULTS.test.DATABASE_URL); - expect(value.JWT_SECRET).toBe(NON_PRODUCTION_DEFAULTS.test.JWT_SECRET); - expect(value.ASSET_SIGNING_SECRET).toBe( - NON_PRODUCTION_DEFAULTS.test.ASSET_SIGNING_SECRET, - ); - }); - - it('keeps development and test defaults distinct from each other', () => { - expect(NON_PRODUCTION_DEFAULTS.development.DATABASE_URL).not.toBe( - NON_PRODUCTION_DEFAULTS.test.DATABASE_URL, - ); - expect(NON_PRODUCTION_DEFAULTS.development.JWT_SECRET).not.toBe( - NON_PRODUCTION_DEFAULTS.test.JWT_SECRET, - ); - }); - - it('applies asset quota defaults', () => { - const { value } = validate({}); - - expect(value.ASSETS_MAX_SIZE_MB).toBe(10); - expect(value.ASSETS_MAX_TOTAL_MB).toBe(1024); - expect(value.ASSETS_MAX_COUNT).toBe(10_000); - expect(value.ASSETS_UPLOAD_DIR).toBe('./data/uploads'); - }); - - it('applies contract, job, migration and notification defaults', () => { - const { value } = validate({}); - - expect(value.CONTRACT_INGESTION_ENABLED).toBe('false'); - expect(value.CONTRACT_ADAPTER_MODE).toBe('mock'); - expect(value.CONTRACT_EVENT_RETENTION_DAYS).toBe(90); - expect(value.MAX_JOB_RETRIES).toBe(3); - expect(value.MIGRATION_STRICT_ORDERING).toBe('true'); - expect(value.NOTIFICATION_DEFAULT_CHANNEL).toBe('all'); - }); - }); - - describe('production requirements', () => { - it('rejects a production boot that is missing secrets and persistence', () => { - const { error } = validate({ NODE_ENV: 'production' }); - - expect(error).toBeDefined(); - const message = error!.message; - for (const key of [ - 'DATABASE_URL', - 'REDIS_HOST', - 'JWT_SECRET', - 'ASSET_SIGNING_SECRET', - ]) { - expect(message).toContain(key); - } - }); - - it('accepts a fully configured production environment', () => { - const { error, value } = validate(validProductionEnv); - - expect(error).toBeUndefined(); - expect(value.NODE_ENV).toBe('production'); - expect(value.JWT_SECRET).toBe(PRODUCTION_SECRET); - }); - - it('does not fall back to development defaults in production', () => { - const { error } = validate({ - ...validProductionEnv, - DATABASE_URL: undefined, - }); - - expect(error).toBeDefined(); - expect(error!.message).toContain('DATABASE_URL'); - }); - - it('rejects low-entropy production secrets', () => { - const { error } = validate({ - ...validProductionEnv, - JWT_SECRET: 'short-secret', - }); - - expect(error).toBeDefined(); - expect(error!.message).toContain( - `at least ${MIN_PRODUCTION_SECRET_LENGTH} characters`, - ); - }); - - it.each(FORBIDDEN_PRODUCTION_SECRETS.map((secret) => [secret]))( - 'rejects the placeholder secret %s in production', - (placeholder) => { - const { error } = validate({ - ...validProductionEnv, - JWT_SECRET: placeholder, - }); - - expect(error).toBeDefined(); - expect(error!.message).toContain('JWT_SECRET'); - }, - ); - - it('rejects the non-production defaults being reused in production', () => { - for (const defaults of Object.values(NON_PRODUCTION_DEFAULTS)) { - const { error } = validate({ - ...validProductionEnv, - JWT_SECRET: defaults.JWT_SECRET, - ASSET_SIGNING_SECRET: defaults.ASSET_SIGNING_SECRET, - }); - - expect(error).toBeDefined(); - expect(error!.message).toContain('JWT_SECRET'); - expect(error!.message).toContain('ASSET_SIGNING_SECRET'); - } - }); - - it('rejects a database URL with an unsupported scheme', () => { - const { error } = validate({ - ...validProductionEnv, - DATABASE_URL: 'ftp://app:pw@db.internal/rustacademy', - }); - - expect(error).toBeDefined(); - expect(error!.message).toContain('DATABASE_URL'); - }); - - it('requires the provider API key that the selected AI provider needs', () => { - expect(validate({ AI_PROVIDER: 'claude' }).error?.message).toContain( - 'ANTHROPIC_API_KEY', - ); - expect(validate({ AI_PROVIDER: 'openai' }).error?.message).toContain( - 'OPENAI_API_KEY', - ); - expect(validate({ AI_PROVIDER: 'mock' }).error).toBeUndefined(); - }); - }); - - describe('secret redaction', () => { - const leakyValue = 'sup3r-s3cret-value-that-must-never-be-printed'; - - it('never echoes a rejected secret value in the error message', () => { - const { error } = validate({ - ...validProductionEnv, - JWT_SECRET: leakyValue.slice(0, 20), - ASSET_SIGNING_SECRET: leakyValue.slice(0, 12), - }); - - expect(error).toBeDefined(); - expect(error!.message).toContain('JWT_SECRET'); - expect(error!.message).not.toContain(leakyValue.slice(0, 12)); - }); - - it('never echoes a malformed database URL, which carries credentials', () => { - const dbUrl = `ftp://app:${leakyValue}@db.internal/rustacademy`; - const { error } = validate({ ...validProductionEnv, DATABASE_URL: dbUrl }); - - expect(error).toBeDefined(); - expect(error!.message).not.toContain(leakyValue); - expect(error!.message).not.toContain(dbUrl); - }); - - it('keeps every secret key out of the rendered message', () => { - const env: Record = { NODE_ENV: 'production' }; - for (const key of SECRET_ENV_KEYS) { - env[key] = leakyValue; - } - - const { error } = validate(env); - - expect(error).toBeDefined(); - expect(error!.message).not.toContain(leakyValue); - }); - }); - - describe('unknown variables', () => { - it('tolerates unrelated process variables by default', () => { - const { error, value } = validate({ - PATH: '/usr/local/bin', - HOME: '/home/node', - CI: 'true', - SOME_FUTURE_FLAG: 'yes', - }); - - expect(error).toBeUndefined(); - expect(value.SOME_FUTURE_FLAG).toBe('yes'); - }); - - it('rejects unknown variables when strict mode is requested explicitly', () => { - const { error } = envValidationSchema.validate( - { UNKNOWN_VALUE: 'value' }, - { ...ENV_VALIDATION_OPTIONS, allowUnknown: false }, - ); - - expect(error).toBeDefined(); - expect(error!.message).toContain('UNKNOWN_VALUE'); - }); - }); - - describe('deterministic failures', () => { - it('reports every invalid variable in a single pass', () => { - const { error } = validate({ - NODE_ENV: 'staging', - PORT: 'not-a-port', - REDIS_PORT: -1, - AI_TEMPERATURE: 9, - }); - - expect(error).toBeDefined(); - expect(error!.details.length).toBeGreaterThanOrEqual(4); - for (const key of ['NODE_ENV', 'PORT', 'REDIS_PORT', 'AI_TEMPERATURE']) { - expect(error!.message).toContain(key); - } - }); - - it('produces the same error for the same input every time', () => { - const env = { NODE_ENV: 'production', PORT: 'nope' }; - - const first = validate(env).error?.message; - const second = validate(env).error?.message; - - expect(first).toBeDefined(); - expect(second).toBe(first); - }); - - it('coerces string env values into their declared types', () => { - const { error, value } = validate({ - PORT: '8080', - REDIS_PORT: '6380', - AI_MAX_TOKENS: '2048', - EXPORT_NOTIFICATION_ENABLED: 'false', - }); - - expect(error).toBeUndefined(); - expect(value.PORT).toBe(8080); - expect(value.REDIS_PORT).toBe(6380); - expect(value.AI_MAX_TOKENS).toBe(2048); - expect(value.EXPORT_NOTIFICATION_ENABLED).toBe(false); - }); - }); - - describe('CORS_ORIGIN parsing', () => { - it('keeps the wildcard as-is', () => { - expect(validate({ CORS_ORIGIN: '*' }).value.CORS_ORIGIN).toBe('*'); - }); - - it('returns a single origin as a plain string', () => { - expect( - validate({ CORS_ORIGIN: 'https://rustacademy.xyz' }).value.CORS_ORIGIN, - ).toBe('https://rustacademy.xyz'); - }); - - it('splits a comma-separated list into an array of origins', () => { - expect( - validate({ - CORS_ORIGIN: 'https://a.example, https://b.example ,', - }).value.CORS_ORIGIN, - ).toEqual(['https://a.example', 'https://b.example']); - }); - - it('rejects an origin list that resolves to nothing', () => { - expect(validate({ CORS_ORIGIN: ' , , ' }).error).toBeDefined(); - }); - }); - - describe('feature-flag helpers', () => { - it('treats only the literal string "true" as enabled', () => { - expect(isFeatureEnabled('true')).toBe(true); - expect(isFeatureEnabled('false')).toBe(false); - expect(isFeatureEnabled('TRUE')).toBe(false); - expect(isFeatureEnabled(undefined)).toBe(false); - }); - - it('treats only the literal string "false" as explicitly disabled', () => { - expect(isFeatureExplicitlyDisabled('false')).toBe(true); - expect(isFeatureExplicitlyDisabled('true')).toBe(false); - expect(isFeatureExplicitlyDisabled(undefined)).toBe(false); - }); - }); -}); diff --git a/BackendAcademy/src/config/jwt-clock-skew.config.spec.ts b/BackendAcademy/src/config/jwt-clock-skew.config.spec.ts deleted file mode 100644 index c93f4ad995..0000000000 --- a/BackendAcademy/src/config/jwt-clock-skew.config.spec.ts +++ /dev/null @@ -1,41 +0,0 @@ -import * as Joi from 'joi'; - -/** - * BA-023 — the allowed JWT clock skew is explicit and BOUNDED. - * - * The config validation schema must reject any value that would widen the - * tolerance beyond the hard maximum, accepting only values within - * [0, JWT_HARD_MAX_SKEW_SECONDS]. - */ - -const JWT_HARD_MAX_SKEW_SECONDS = 120; - -const schema = Joi.object({ - JWT_SECRET: Joi.string().optional(), - JWT_CLOCK_SKEW_SECONDS: Joi.number().integer().min(0).max(120).default(30), -}); - -describe('JWT clock skew config validation (BA-023)', () => { - it('defaults the allowed skew when unset', () => { - const { value } = schema.validate({}); - expect(value).toEqual({ JWT_CLOCK_SKEW_SECONDS: 30 }); - }); - - it('accepts an explicit in-range skew', () => { - const { error, value } = schema.validate({ - JWT_CLOCK_SKEW_SECONDS: JWT_HARD_MAX_SKEW_SECONDS, - }); - expect(error).toBeUndefined(); - expect(value.JWT_CLOCK_SKEW_SECONDS).toBe(JWT_HARD_MAX_SKEW_SECONDS); - }); - - it('rejects a skew wider than the hard maximum', () => { - const { error } = schema.validate({ JWT_CLOCK_SKEW_SECONDS: 3600 }); - expect(error).toBeDefined(); - }); - - it('rejects negative or non-integer skew', () => { - expect(schema.validate({ JWT_CLOCK_SKEW_SECONDS: -5 }).error).toBeDefined(); - expect(schema.validate({ JWT_CLOCK_SKEW_SECONDS: 1.5 }).error).toBeDefined(); - }); -}); diff --git a/BackendAcademy/src/contracts/contract-registry.service.spec.ts b/BackendAcademy/src/contracts/contract-registry.service.spec.ts deleted file mode 100644 index 6e628d4a33..0000000000 --- a/BackendAcademy/src/contracts/contract-registry.service.spec.ts +++ /dev/null @@ -1,371 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { BadRequestException } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { ContractRegistryService } from './contract-registry.service'; -import { ContractRegistryEntry } from './interfaces/contracts.interface'; - -// --------------------------------------------------------------------------- -// Helper to build a valid registry entry -// --------------------------------------------------------------------------- -const validEntry = (overrides?: Partial>) => ({ - contractId: 'CRUSTDEFIABCDEFGHIJKLMNOPQRSTUVWXYZ12', - wasmHash: 'a'.repeat(64), - network: 'testnet' as const, - deployedBy: 'G' + 'B'.repeat(55), - version: 1, - schema: { - version: '1.0.0', - contractType: 'token', - entryPoints: ['transfer', 'balance', 'approve'], - methods: [ - { name: 'transfer', args: ['from', 'to', 'amount'], returnType: 'bool' }, - { name: 'balance', args: ['owner'], returnType: 'u128' }, - ], - isMainnetCompatible: false, - metadata: { author: 'rustacademy' }, - }, - ...overrides, -}); - -describe('ContractRegistryService', () => { - let service: ContractRegistryService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - ContractRegistryService, - { - provide: ConfigService, - useValue: { - get: jest.fn((key: string, defaultValue?: unknown) => { - const overrides: Record = { - CONTRACT_REGISTRY_REQUIRE_SCHEMA: 'true', - CONTRACT_SCHEMA_VERSION: '1.0.0', - CONTRACT_REGISTRY_MAX_ENTRIES: 1000, - }; - return overrides[key] ?? defaultValue; - }), - }, - }, - ], - }).compile(); - - service = module.get(ContractRegistryService); - }); - - // ========================================================================= - // register() - // ========================================================================= - - describe('register(entry)', () => { - it('registers a valid contract entry', async () => { - const result = await service.register(validEntry()); - - expect(result.id).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, - ); - expect(result.contractId).toBe('CRUSTDEFIABCDEFGHIJKLMNOPQRSTUVWXYZ12'); - expect(result.validationStatus).toBe('valid'); - expect(result.registeredAt).toBeInstanceOf(Date); - expect(result.validatedAt).toBeInstanceOf(Date); - }); - - it('throws on missing contractId', async () => { - await expect( - service.register(validEntry({ contractId: '' })), - ).rejects.toThrow(BadRequestException); - }); - - it('throws on invalid contractId format', async () => { - await expect( - service.register(validEntry({ contractId: 'bad-format' })), - ).rejects.toThrow(BadRequestException); - }); - - it('throws on missing wasmHash', async () => { - await expect( - service.register(validEntry({ wasmHash: '' })), - ).rejects.toThrow(BadRequestException); - }); - - it('throws on invalid wasmHash format', async () => { - await expect( - service.register(validEntry({ wasmHash: 'not-hex' })), - ).rejects.toThrow(BadRequestException); - }); - - it('throws on missing network', async () => { - await expect( - service.register(validEntry({ network: undefined as unknown as 'testnet' })), - ).rejects.toThrow(BadRequestException); - }); - - it('throws on invalid network value', async () => { - await expect( - service.register(validEntry({ network: 'ethereum' as 'testnet' })), - ).rejects.toThrow(BadRequestException); - }); - - it('throws on missing deployedBy', async () => { - await expect( - service.register(validEntry({ deployedBy: '' })), - ).rejects.toThrow(BadRequestException); - }); - - it('throws on invalid deployedBy (wrong prefix)', async () => { - await expect( - service.register(validEntry({ deployedBy: 'X' + 'B'.repeat(55) })), - ).rejects.toThrow(BadRequestException); - }); - - it('throws on invalid deployedBy (wrong length)', async () => { - await expect( - service.register(validEntry({ deployedBy: 'GABC' })), - ).rejects.toThrow(BadRequestException); - }); - - it('throws on invalid version (negative)', async () => { - await expect( - service.register(validEntry({ version: -1 })), - ).rejects.toThrow(BadRequestException); - }); - - it('throws on version 0', async () => { - await expect( - service.register(validEntry({ version: 0 })), - ).rejects.toThrow(BadRequestException); - }); - - it('throws on invalid version (not a number)', async () => { - await expect( - service.register(validEntry({ version: 'abc' as unknown as number })), - ).rejects.toThrow(BadRequestException); - }); - - it('throws on duplicate contractId', async () => { - await service.register(validEntry()); - await expect(service.register(validEntry())).rejects.toThrow( - BadRequestException, - ); - }); - - // ── Schema compatibility ─────────────────────────────────────── - - it('throws when schema is missing and requireSchema is true', async () => { - await expect( - service.register(validEntry({ schema: undefined })), - ).rejects.toThrow(BadRequestException); - }); - - it('throws on incompatible schema version', async () => { - await expect( - service.register( - validEntry({ schema: { ...validEntry().schema!, version: '2.0.0' } }), - ), - ).rejects.toThrow(BadRequestException); - }); - - it('throws when schema has no entryPoints', async () => { - await expect( - service.register( - validEntry({ schema: { ...validEntry().schema!, entryPoints: [] } }), - ), - ).rejects.toThrow(BadRequestException); - }); - - it('throws when schema has no contractType', async () => { - await expect( - service.register( - validEntry({ schema: { ...validEntry().schema!, contractType: '' } }), - ), - ).rejects.toThrow(BadRequestException); - }); - - it('accepts mainnet contract without mainnet compatibility flag (status: valid)', async () => { - const result = await service.register( - validEntry({ - network: 'mainnet', - schema: { ...validEntry().schema!, isMainnetCompatible: false }, - }), - ); - expect(result.validationStatus).toBe('valid'); - }); - - it('accepts schema with compatible minor version bump', async () => { - const result = await service.register( - validEntry({ schema: { ...validEntry().schema!, version: '1.5.0' } }), - ); - expect(result.validationStatus).toBe('valid'); - }); - }); - - // ========================================================================= - // get() - // ========================================================================= - - describe('get(contractId)', () => { - it('returns an entry for a registered contract', async () => { - await service.register(validEntry()); - const entry = service.get('CRUSTDEFIABCDEFGHIJKLMNOPQRSTUVWXYZ12'); - expect(entry).toBeDefined(); - expect(entry!.contractId).toBe('CRUSTDEFIABCDEFGHIJKLMNOPQRSTUVWXYZ12'); - }); - - it('returns undefined for unknown contract', () => { - expect(service.get('CRUSTDEFIABCDEFGHIJKLMNOPQRSTUVWXYZ99')).toBeUndefined(); - }); - }); - - // ========================================================================= - // list() - // ========================================================================= - - describe('list(filter?)', () => { - beforeEach(async () => { - await service.register(validEntry({ contractId: 'CRUSTDEFIABCDEFGHIJKLMNOPQRSTUVWXYZ11' })); - await service.register( - validEntry({ - contractId: 'CRUSTDEFIABCDEFGHIJKLMNOPQRSTUVWXYZ22', - network: 'futurenet', - deployedBy: 'G' + 'C'.repeat(55), - }), - ); - }); - - it('returns all entries without filter', () => { - expect(service.list()).toHaveLength(2); - }); - - it('filters by network', () => { - const results = service.list({ network: 'futurenet' }); - expect(results).toHaveLength(1); - expect(results[0].network).toBe('futurenet'); - }); - - it('filters by validationStatus', () => { - const results = service.list({ validationStatus: 'valid' }); - expect(results.every((e) => e.validationStatus === 'valid')).toBe(true); - }); - - it('filters by deployedBy', () => { - const results = service.list({ deployedBy: 'G' + 'C'.repeat(55) }); - expect(results).toHaveLength(1); - expect(results[0].deployedBy).toBe('G' + 'C'.repeat(55)); - }); - - it('returns empty array when no matches', () => { - expect(service.list({ network: 'mainnet' })).toEqual([]); - }); - }); - - // ========================================================================= - // deregister() - // ========================================================================= - - describe('deregister(contractId)', () => { - it('removes a registered contract', async () => { - await service.register(validEntry()); - expect(service.deregister('CRUSTDEFIABCDEFGHIJKLMNOPQRSTUVWXYZ12')).toBe(true); - expect(service.get('CRUSTDEFIABCDEFGHIJKLMNOPQRSTUVWXYZ12')).toBeUndefined(); - }); - - it('returns false for unknown contract', () => { - expect(service.deregister('CRUSTDEFIABCDEFGHIJKLMNOPQRSTUVWXYZ99')).toBe(false); - }); - }); - - // ========================================================================= - // count - // ========================================================================= - - describe('count', () => { - it('starts at 0', () => { - expect(service.count).toBe(0); - }); - - it('increments after registration', async () => { - await service.register(validEntry()); - expect(service.count).toBe(1); - }); - - it('decrements after deregistration', async () => { - await service.register(validEntry()); - service.deregister('CRUSTDEFIABCDEFGHIJKLMNOPQRSTUVWXYZ12'); - expect(service.count).toBe(0); - }); - }); - - // ========================================================================= - // Max entries enforcement - // ========================================================================= - - describe('max entries enforcement', () => { - it('throws when registry is full', async () => { - // Create service with max of 1 - const module: TestingModule = await Test.createTestingModule({ - providers: [ - ContractRegistryService, - { - provide: ConfigService, - useValue: { - get: jest.fn((key: string, defaultValue?: unknown) => { - const overrides: Record = { - CONTRACT_REGISTRY_REQUIRE_SCHEMA: 'true', - CONTRACT_SCHEMA_VERSION: '1.0.0', - CONTRACT_REGISTRY_MAX_ENTRIES: 1, - }; - return overrides[key] ?? defaultValue; - }), - }, - }, - ], - }).compile(); - - const limitedService = module.get(ContractRegistryService); - - await limitedService.register(validEntry({ contractId: 'CRUSTDEFIABCDEFGHIJKLMNOPQRSTUVWXYZ01' })); - - await expect( - limitedService.register(validEntry({ contractId: 'CRUSTDEFIABCDEFGHIJKLMNOPQRSTUVWXYZ02' })), - ).rejects.toThrow(BadRequestException); - }); - }); - - // ========================================================================= - // Schema not required (permissive mode) - // ========================================================================= - - describe('when CONTRACT_REGISTRY_REQUIRE_SCHEMA is false', () => { - let permissiveService: ContractRegistryService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - ContractRegistryService, - { - provide: ConfigService, - useValue: { - get: jest.fn((key: string, defaultValue?: unknown) => { - const overrides: Record = { - CONTRACT_REGISTRY_REQUIRE_SCHEMA: 'false', - CONTRACT_SCHEMA_VERSION: '1.0.0', - CONTRACT_REGISTRY_MAX_ENTRIES: 1000, - }; - return overrides[key] ?? defaultValue; - }), - }, - }, - ], - }).compile(); - - permissiveService = module.get(ContractRegistryService); - }); - - it('accepts entries with schema issues as warnings', async () => { - const result = await permissiveService.register( - validEntry({ schema: { ...validEntry().schema!, version: '2.0.0' } }), - ); - expect(result.validationStatus).toBe('warning'); - }); - }); -}); diff --git a/BackendAcademy/src/contracts/contract-registry.service.ts b/BackendAcademy/src/contracts/contract-registry.service.ts deleted file mode 100644 index 62e6e11a65..0000000000 --- a/BackendAcademy/src/contracts/contract-registry.service.ts +++ /dev/null @@ -1,292 +0,0 @@ -import { - BadRequestException, - Injectable, - Logger, - OnModuleInit, -} from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { v4 as uuidv4 } from 'uuid'; -import { isFeatureEnabled } from '../config/env.schema'; -import { - ContractRegistryEntry, - ContractRegistryValidationResult, - ContractSchemaDefinition, - ContractRegistryFilter, -} from './interfaces/contracts.interface'; - -/** - * Validates and manages contract registry entries with schema - * compatibility checks. - * - * Addresses issue #393: contract registry entries must not be accepted - * without validating required fields, version expectations, and - * schema compatibility against the configured contract schema version. - */ -@Injectable() -export class ContractRegistryService implements OnModuleInit { - private readonly logger = new Logger(ContractRegistryService.name); - private readonly registry = new Map(); - private readonly schemaRequired: boolean; - - constructor(private readonly configService: ConfigService) { - this.schemaRequired = isFeatureEnabled( - this.configService.get('CONTRACT_REGISTRY_REQUIRE_SCHEMA'), - ); - } - - onModuleInit(): void { - this.logger.log( - `ContractRegistryService initialized (schemaRequired=${this.schemaRequired})`, - ); - } - - /** - * Registers a new contract after performing schema compatibility - * validation. Throws BadRequestException if validation fails. - */ - async register( - entry: Omit, - ): Promise { - // ── Required field validation ────────────────────────────────── - this.validateRequiredFields(entry); - - // ── Schema compatibility validation ─────────────────────────── - const validationResult = this.validateSchemaCompatibility(entry); - - if (!validationResult.valid) { - if (this.schemaRequired) { - throw new BadRequestException({ - error: 'CONTRACT_SCHEMA_INCOMPATIBLE', - message: 'Contract registry entry failed schema compatibility checks', - details: validationResult.errors, - }); - } - this.logger.warn( - `Contract "${entry.contractId}" has schema compatibility issues: ${validationResult.errors.join('; ')}`, - ); - } - - // ── Duplicate check ─────────────────────────────────────────── - if (this.registry.has(entry.contractId)) { - throw new BadRequestException({ - error: 'CONTRACT_ALREADY_REGISTERED', - message: `Contract "${entry.contractId}" is already registered`, - }); - } - - // ── Max entries check ───────────────────────────────────────── - const maxEntries = this.configService.get( - 'CONTRACT_REGISTRY_MAX_ENTRIES', - 1000, - ); - if (this.registry.size >= maxEntries) { - throw new BadRequestException({ - error: 'REGISTRY_FULL', - message: `Contract registry has reached maximum capacity of ${maxEntries} entries`, - }); - } - - const now = new Date(); - const registered: ContractRegistryEntry = { - ...entry, - id: uuidv4(), - registeredAt: now, - validatedAt: now, - validationStatus: validationResult.valid ? 'valid' : 'warning', - }; - - this.registry.set(entry.contractId, registered); - this.logger.log( - `Contract "${entry.contractId}" registered (status=${registered.validationStatus})`, - ); - - return registered; - } - - /** - * Returns a contract registry entry by contract ID. - */ - get(contractId: string): ContractRegistryEntry | undefined { - return this.registry.get(contractId); - } - - /** - * Lists registry entries, optionally filtered. - */ - list(filter?: ContractRegistryFilter): ContractRegistryEntry[] { - let entries = Array.from(this.registry.values()); - - if (filter?.network) { - entries = entries.filter((e) => e.network === filter.network); - } - if (filter?.validationStatus) { - entries = entries.filter((e) => e.validationStatus === filter.validationStatus); - } - if (filter?.deployedBy) { - entries = entries.filter((e) => e.deployedBy === filter.deployedBy); - } - - return entries; - } - - /** - * Removes a registry entry by contract ID. - */ - deregister(contractId: string): boolean { - return this.registry.delete(contractId); - } - - /** - * Returns the total number of registered contracts. - */ - get count(): number { - return this.registry.size; - } - - // ── Private validation helpers ────────────────────────────────── - - /** - * Validates that all required fields are present and well-formed. - */ - private validateRequiredFields( - entry: Omit, - ): void { - const errors: string[] = []; - - if (!entry.contractId?.trim()) { - errors.push('contractId is required and must be non-empty'); - } else if (!/^C[A-Z0-9]{32,}$/.test(entry.contractId)) { - errors.push( - 'contractId must start with "C" followed by at least 32 alphanumeric characters', - ); - } - - if (!entry.wasmHash?.trim()) { - errors.push('wasmHash is required and must be non-empty'); - } else if (!/^[a-fA-F0-9]{64}$/.test(entry.wasmHash)) { - errors.push('wasmHash must be a 64-character hex string'); - } - - if (!entry.network) { - errors.push('network is required'); - } else if (!['testnet', 'futurenet', 'mainnet'].includes(entry.network)) { - errors.push( - `network must be one of "testnet", "futurenet", or "mainnet", got "${entry.network}"`, - ); - } - - if (!entry.deployedBy?.trim()) { - errors.push('deployedBy is required'); - } else if (!entry.deployedBy.startsWith('G') || entry.deployedBy.length !== 56) { - errors.push( - 'deployedBy must be a valid Stellar public key (starts with G, 56 characters)', - ); - } - - if (entry.version !== undefined) { - if (typeof entry.version !== 'number' || entry.version < 1) { - errors.push('version must be a positive integer >= 1'); - } - } - - if (errors.length > 0) { - throw new BadRequestException({ - error: 'CONTRACT_REGISTRY_VALIDATION_FAILED', - message: 'Required field validation failed', - details: errors, - }); - } - } - - /** - * Validates schema compatibility of a contract registry entry against - * the configured contract schema version. - */ - private validateSchemaCompatibility( - entry: Omit, - ): ContractRegistryValidationResult { - const errors: string[] = []; - const warnings: string[] = []; - const requiredVersion = this.configService.get( - 'CONTRACT_SCHEMA_VERSION', - '1.0.0', - ); - - // ── Schema field validation ────────────────────────────────── - if (!entry.schema) { - errors.push('schema is required for schema compatibility validation'); - return { valid: false, errors }; - } - - const schema = entry.schema; - - // Version compatibility - if (!schema.version) { - errors.push('schema.version is required'); - } else if (!this.isVersionCompatible(schema.version, requiredVersion)) { - errors.push( - `schema.version "${schema.version}" is incompatible with required version "${requiredVersion}"`, - ); - } - - // Required schema fields - if (!schema.contractType || !schema.contractType.trim()) { - errors.push('schema.contractType is required'); - } - - if (!Array.isArray(schema.entryPoints) || schema.entryPoints.length === 0) { - errors.push('schema.entryPoints must be a non-empty array'); - } - - // Method signature validation - if (Array.isArray(schema.methods)) { - for (const method of schema.methods) { - if (!method.name?.trim()) { - errors.push(`schema.methods contains a method with missing name`); - } - if (!Array.isArray(method.args)) { - errors.push( - `schema.methods.${method.name}: args must be an array`, - ); - } - } - } - - // Network compatibility check - if (entry.network === 'mainnet') { - if (!schema.isMainnetCompatible) { - warnings.push( - 'Contract registered on mainnet but schema does not declare mainnet compatibility', - ); - } - } - - return { - valid: errors.length === 0, - errors, - warnings: warnings.length > 0 ? warnings : undefined, - }; - } - - /** - * Checks whether a schema version string is compatible with the - * required version. Compatible means same major version and - * minor version >= required. - */ - private isVersionCompatible( - version: string, - required: string, - ): boolean { - const parse = (v: string): [number, number, number] => { - const parts = v.split('.').map(Number); - return [parts[0] || 0, parts[1] || 0, parts[2] || 0]; - }; - - const [vMajor, vMinor] = parse(version); - const [rMajor, rMinor] = parse(required); - - if (vMajor !== rMajor) return false; - if (vMinor < rMinor) return false; - return true; - } -} diff --git a/BackendAcademy/src/contracts/contracts.controller.spec.ts b/BackendAcademy/src/contracts/contracts.controller.spec.ts deleted file mode 100644 index 37e8e243a9..0000000000 --- a/BackendAcademy/src/contracts/contracts.controller.spec.ts +++ /dev/null @@ -1,644 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { ContractsController } from './contracts.controller'; -import { ContractsService } from './contracts.service'; -import { ContractRegistryService } from './contract-registry.service'; -import { MetricsService } from '../monitoring/metrics.service'; -import { - ContractDeployment, - ContractEventLogEntry, - ContractHealth, - ContractInfo, - ContractInvocationResult, - ContractRegistryEntry, - ReplayResult, - StateReconciliationResult, -} from './interfaces/contracts.interface'; - -// --------------------------------------------------------------------------- -// Type-safe mock helpers -// --------------------------------------------------------------------------- - -type DeepPartial = T extends object - ? { [P in keyof T]?: DeepPartial } - : T; - -type MockService = { - [K in keyof T]: T[K] extends (...args: infer A) => infer R - ? jest.Mock - : T[K]; -}; - -// --------------------------------------------------------------------------- - -describe('ContractsController', () => { - let controller: ContractsController; - let contractsService: MockService; - let registryService: MockService; - let metricsService: MockService; - - beforeEach(async () => { - const mockContractsService: MockService = { - getReputation: jest.fn(), - updateReputation: jest.fn(), - issueCertificate: jest.fn(), - getCertificate: jest.fn(), - listCertificates: jest.fn(), - issueBadge: jest.fn(), - getBadge: jest.fn(), - listBadges: jest.fn(), - createPayout: jest.fn(), - getPayout: jest.fn(), - releasePayout: jest.fn(), - createProposal: jest.fn(), - getProposal: jest.fn(), - listProposals: jest.fn(), - castVote: jest.fn(), - invokeContract: jest.fn(), - deployContract: jest.fn(), - getContractInfo: jest.fn(), - getContractHealth: jest.fn(), - getInvocationHistory: jest.fn(), - getAllDeployments: jest.fn(), - getEventLog: jest.fn(), - replayEvents: jest.fn(), - reconcileState: jest.fn(), - getReplayHistory: jest.fn(), - getEventLogStats: jest.fn(), - healthCheck: jest.fn(), - recordReward: jest.fn(), - recordPayment: jest.fn(), - mintCertificate: jest.fn(), - mintBadge: jest.fn(), - }; - - const mockRegistryService: MockService = { - register: jest.fn(), - get: jest.fn(), - list: jest.fn(), - deregister: jest.fn(), - } as unknown as MockService; - - const mockMetricsService: MockService = { - incrementCounter: jest.fn(), - setGauge: jest.fn(), - recordLatency: jest.fn(), - getAllMetrics: jest.fn(), - trackRequest: jest.fn(), - getRequestStats: jest.fn(), - getCronHealth: jest.fn(), - recordCronRun: jest.fn(), - recordCronError: jest.fn(), - } as unknown as MockService; - - const module: TestingModule = await Test.createTestingModule({ - controllers: [ContractsController], - providers: [ - { provide: ContractsService, useValue: mockContractsService }, - { provide: ContractRegistryService, useValue: mockRegistryService }, - { provide: MetricsService, useValue: mockMetricsService }, - ], - }).compile(); - - controller = module.get(ContractsController); - contractsService = module.get(ContractsService) as unknown as MockService; - registryService = module.get(ContractRegistryService) as unknown as MockService; - metricsService = module.get(MetricsService) as unknown as MockService; - }); - - // ========================================================================= - // Reputation endpoints - // ========================================================================= - - describe('GET contracts/reputation/:userId', () => { - it('delegates to contractsService.getReputation', () => { - const expected = { userId: 'user1', score: 100, level: 2, lastUpdated: new Date() }; - contractsService.getReputation.mockReturnValue(expected); - - const result = controller.getReputation('user1'); - expect(result).toBe(expected); - expect(contractsService.getReputation).toHaveBeenCalledWith('user1'); - }); - }); - - describe('POST contracts/reputation/:userId', () => { - it('delegates to contractsService.updateReputation', () => { - const expected = { success: true, data: { userId: 'user1', score: 500, level: 6, lastUpdated: new Date() } }; - contractsService.updateReputation.mockReturnValue(expected); - - const result = controller.updateReputation('user1', 500); - expect(result).toBe(expected); - expect(contractsService.updateReputation).toHaveBeenCalledWith('user1', 500); - }); - }); - - // ========================================================================= - // Certificate endpoints - // ========================================================================= - - describe('POST contracts/certificates/issue', () => { - it('delegates to contractsService.issueCertificate', () => { - const expected = { success: true, data: { id: 'cert_1', userId: 'user1', courseId: 'course-1', issuedAt: new Date() } }; - contractsService.issueCertificate.mockReturnValue(expected); - - const result = controller.issueCertificate('user1', 'course-1'); - expect(result).toBe(expected); - expect(contractsService.issueCertificate).toHaveBeenCalledWith('user1', 'course-1'); - }); - }); - - describe('GET contracts/certificates/:id', () => { - it('delegates to contractsService.getCertificate', () => { - const expected = { id: 'cert_abc', userId: 'user1', courseId: 'c1', issuedAt: new Date() }; - contractsService.getCertificate.mockReturnValue(expected); - - const result = controller.getCertificate('cert_abc'); - expect(result).toBe(expected); - }); - }); - - describe('GET contracts/certificates/user/:userId', () => { - it('delegates to contractsService.listCertificates', () => { - const expected = [{ id: 'cert_1', userId: 'user1', courseId: 'c1', issuedAt: new Date() }]; - contractsService.listCertificates.mockReturnValue(expected); - - const result = controller.listCertificates('user1'); - expect(result).toBe(expected); - }); - }); - - // ========================================================================= - // Badge endpoints - // ========================================================================= - - describe('POST contracts/badges/issue', () => { - it('delegates to contractsService.issueBadge', () => { - const expected = { success: true, data: { id: 'badge_1', userId: 'user1', badgeType: 'streak-master', issuedAt: new Date() } }; - contractsService.issueBadge.mockReturnValue(expected); - - const result = controller.issueBadge('user1', 'streak-master'); - expect(result).toBe(expected); - expect(contractsService.issueBadge).toHaveBeenCalledWith('user1', 'streak-master'); - }); - }); - - describe('GET contracts/badges/:id', () => { - it('delegates to contractsService.getBadge', () => { - const expected = { id: 'badge_abc', userId: 'user1', badgeType: 'early-adopter', issuedAt: new Date() }; - contractsService.getBadge.mockReturnValue(expected); - expect(controller.getBadge('badge_abc')).toBe(expected); - }); - }); - - describe('GET contracts/badges/user/:userId', () => { - it('delegates to contractsService.listBadges', () => { - const expected = [{ id: 'badge_1', userId: 'user1', badgeType: 'a', issuedAt: new Date() }]; - contractsService.listBadges.mockReturnValue(expected); - expect(controller.listBadges('user1')).toBe(expected); - }); - }); - - // ========================================================================= - // Payout endpoints - // ========================================================================= - - describe('POST contracts/payouts/create', () => { - it('delegates to contractsService.createPayout', () => { - const expected = { success: true, data: { id: 'payout_1', userId: 'user1', amount: 100, currency: 'XLM', status: 'pending' as const, createdAt: new Date() } }; - contractsService.createPayout.mockReturnValue(expected); - - const result = controller.createPayout('user1', 100, 'XLM'); - expect(result).toBe(expected); - }); - }); - - describe('GET contracts/payouts/:id', () => { - it('delegates to contractsService.getPayout', () => { - const expected = { id: 'payout_abc', userId: 'user1', amount: 100, currency: 'XLM', status: 'pending' as const, createdAt: new Date() }; - contractsService.getPayout.mockReturnValue(expected); - expect(controller.getPayout('payout_abc')).toBe(expected); - }); - }); - - describe('POST contracts/payouts/:id/release', () => { - it('delegates to contractsService.releasePayout', () => { - const expected = { success: true, data: { id: 'payout_abc', userId: 'user1', amount: 100, currency: 'XLM', status: 'completed' as const, createdAt: new Date() } }; - contractsService.releasePayout.mockReturnValue(expected); - expect(controller.releasePayout('payout_abc')).toBe(expected); - }); - }); - - // ========================================================================= - // Governance endpoints - // ========================================================================= - - describe('POST contracts/governance/proposals', () => { - it('delegates to contractsService.createProposal', () => { - const dto = { title: 'T', description: 'D', proposer: 'user1' }; - const expected = { - success: true, - message: 'Proposal created', - data: { - id: 'prop_1', title: 'T', description: 'D', proposer: 'user1', - yesVotes: 0, noVotes: 0, status: 'active' as const, createdAt: new Date(), - }, - }; - contractsService.createProposal.mockReturnValue(expected); - - const result = controller.createProposal(dto); - expect(result).toBe(expected); - expect(contractsService.createProposal).toHaveBeenCalledWith('T', 'D', 'user1'); - }); - }); - - describe('GET contracts/governance/proposals', () => { - it('delegates to contractsService.listProposals', () => { - const expected = [{ - id: 'prop_1', title: 'T', description: 'D', proposer: 'user1', - yesVotes: 0, noVotes: 0, status: 'active' as const, createdAt: new Date(), - }]; - contractsService.listProposals.mockReturnValue(expected); - expect(controller.listProposals()).toBe(expected); - }); - }); - - describe('GET contracts/governance/proposals/:id', () => { - it('delegates to contractsService.getProposal', () => { - const expected = { - id: 'prop_1', title: 'T', description: 'D', proposer: 'user1', - yesVotes: 0, noVotes: 0, status: 'active' as const, createdAt: new Date(), - }; - contractsService.getProposal.mockReturnValue(expected); - expect(controller.getProposal('prop_1')).toBe(expected); - }); - }); - - describe('POST contracts/governance/proposals/:id/vote', () => { - it('delegates to contractsService.castVote', () => { - const dto = { userId: 'voter1', vote: 'yes' as const }; - const expected = { - success: true, - message: 'Vote cast as yes', - data: { - id: 'prop_1', title: 'T', description: 'D', proposer: 'user1', - yesVotes: 1, noVotes: 0, status: 'active' as const, createdAt: new Date(), - }, - }; - contractsService.castVote.mockReturnValue(expected); - - const result = controller.castVote('prop_1', dto); - expect(result).toBe(expected); - expect(contractsService.castVote).toHaveBeenCalledWith('prop_1', 'voter1', 'yes'); - }); - }); - - // ========================================================================= - // Invocation & deployment (gated #395) - // ========================================================================= - - describe('POST contracts/invoke', () => { - it('delegates to contractsService.invokeContract and records metrics', async () => { - const dto = { - contractId: 'CRUSTDEFIABCDEFGHIJKLMNOPQRSTUVWXYZ42', - method: 'transfer', - args: ['a', 'b', '100'], - sourceAccount: 'G' + 'A'.repeat(55), - }; - const expected: ContractInvocationResult = { - invocationId: 'inv_1', - contractId: dto.contractId, - method: 'transfer', - success: true, - result: { ok: true }, - transactionHash: 'a'.repeat(64), - executedAt: new Date(), - }; - contractsService.invokeContract.mockResolvedValue(expected); - - const result = await controller.invokeContract(dto); - expect(result).toBe(expected); - expect(metricsService.incrementCounter).toHaveBeenCalledWith( - 'contract_invocations_total', - 1, - { contractId: dto.contractId, method: dto.method }, - ); - }); - }); - - describe('POST contracts/deploy', () => { - it('delegates to contractsService.deployContract and records metrics', async () => { - const dto = { - contractId: 'CRUSTDEFIABCDEFGHIJKLMNOPQRSTUVWXYZ42', - wasmHash: 'a'.repeat(64), - deployedBy: 'G' + 'B'.repeat(55), - network: 'testnet', - }; - const expected: ContractDeployment = { - contractId: dto.contractId, - wasmHash: dto.wasmHash, - deployedBy: dto.deployedBy, - network: 'testnet', - deployedAt: new Date(), - }; - contractsService.deployContract.mockResolvedValue(expected); - - const result = await controller.deployContract(dto); - expect(result).toBe(expected); - expect(metricsService.incrementCounter).toHaveBeenCalledWith( - 'contract_deployments_total', - 1, - { network: dto.network }, - ); - }); - }); - - describe('GET contracts/:contractId', () => { - it('delegates to contractsService.getContractInfo', async () => { - const expected: ContractInfo = { - contractId: 'c1', - wasmHash: 'a'.repeat(64), - network: 'testnet', - deployedBy: 'G'.repeat(56), - deployedAt: new Date(), - methods: ['balance'], - }; - contractsService.getContractInfo.mockResolvedValue(expected); - - const result = await controller.getContractInfo('c1'); - expect(result).toBe(expected); - }); - }); - - describe('GET contracts/:contractId/health', () => { - it('delegates to contractsService.getContractHealth', async () => { - const expected: ContractHealth = { contractId: 'c1', status: 'active', invocationCount: 5, network: 'testnet' }; - contractsService.getContractHealth.mockResolvedValue(expected); - expect(await controller.getContractHealth('c1')).toBe(expected); - }); - }); - - describe('GET contracts/:contractId/history', () => { - it('delegates to contractsService.getInvocationHistory', async () => { - const expected: ContractInvocationResult[] = [{ - invocationId: 'inv_1', - contractId: 'c1', - method: 'balance', - success: true, - result: { amount: '100' }, - transactionHash: 'a'.repeat(64), - executedAt: new Date(), - }]; - contractsService.getInvocationHistory.mockResolvedValue(expected); - expect(await controller.getInvocationHistory('c1')).toBe(expected); - }); - }); - - describe('GET contracts/', () => { - it('delegates to contractsService.getAllDeployments', async () => { - const expected: ContractDeployment[] = [{ - contractId: 'c1', - wasmHash: 'a'.repeat(64), - deployedBy: 'G'.repeat(56), - network: 'testnet', - deployedAt: new Date(), - }]; - contractsService.getAllDeployments.mockResolvedValue(expected); - expect(await controller.getAllDeployments()).toBe(expected); - }); - }); - - // ========================================================================= - // #393: Registry endpoints - // ========================================================================= - - describe('POST contracts/registry/register', () => { - it('delegates to registryService.register and records metrics', async () => { - const entry = { - contractId: 'CRUSTDEFIABCDEFGHIJKLMNOPQRSTUVWXYZ12', - wasmHash: 'a'.repeat(64), - network: 'testnet' as const, - deployedBy: 'G' + 'B'.repeat(55), - version: 1, - schema: { version: '1.0.0', contractType: 'token', entryPoints: ['transfer'] }, - }; - const expected: ContractRegistryEntry = { - ...entry, - id: 'uuid', - registeredAt: new Date(), - validatedAt: new Date(), - validationStatus: 'valid' as const, - }; - registryService.register.mockResolvedValue(expected); - - const result = await controller.registerContract(entry); - expect(result).toBe(expected); - expect(metricsService.incrementCounter).toHaveBeenCalledWith( - 'contract_registry_entries_total', - 1, - { network: 'testnet', status: 'valid' }, - ); - }); - }); - - describe('GET contracts/registry', () => { - it('delegates to registryService.list and includes total', async () => { - const entries: ContractRegistryEntry[] = [{ - id: 'uuid', - contractId: 'c1', - wasmHash: 'a'.repeat(64), - network: 'testnet', - deployedBy: 'G'.repeat(56), - registeredAt: new Date(), - validatedAt: new Date(), - validationStatus: 'valid', - }]; - registryService.list.mockReturnValue(entries); - - const result = await controller.listRegistry(); - expect(result.entries).toBe(entries); - }); - - it('passes filter to registryService.list', async () => { - registryService.list.mockReturnValue([]); - - const filter = { network: 'testnet' }; - await controller.listRegistry(filter); - expect(registryService.list).toHaveBeenCalledWith(filter); - }); - }); - - describe('GET contracts/registry/:contractId', () => { - it('returns entry when found', async () => { - const expected: ContractRegistryEntry = { - id: 'uuid', - contractId: 'c1', - wasmHash: 'a'.repeat(64), - network: 'testnet', - deployedBy: 'G'.repeat(56), - registeredAt: new Date(), - validatedAt: new Date(), - validationStatus: 'valid', - }; - registryService.get.mockReturnValue(expected); - expect(await controller.getRegistryEntry('c1')).toBe(expected); - }); - - it('returns error object when not found', async () => { - registryService.get.mockReturnValue(undefined); - const result = await controller.getRegistryEntry('c1'); - expect(result).toEqual({ - error: 'CONTRACT_NOT_FOUND', - message: 'Contract c1 not found in registry', - }); - }); - }); - - describe('DELETE contracts/registry/:contractId', () => { - it('delegates to registryService.deregister and records metrics on success', async () => { - registryService.deregister.mockReturnValue(true); - - const result = await controller.deregisterContract('c1'); - expect(result).toEqual({ success: true, contractId: 'c1' }); - expect(metricsService.incrementCounter).toHaveBeenCalledWith( - 'contract_registry_deregistrations_total', - 1, - { contractId: 'c1' }, - ); - }); - - it('does not record metrics on failure', async () => { - registryService.deregister.mockReturnValue(false); - - const result = await controller.deregisterContract('c1'); - expect(result).toEqual({ success: false, contractId: 'c1' }); - // Only check that deregistration was not called with a matching counter - expect(registryService.deregister).toHaveBeenCalledWith('c1'); - }); - }); - - // ========================================================================= - // #394: Event replay & reconciliation endpoints - // ========================================================================= - - describe('GET contracts/:contractId/events', () => { - it('delegates to contractsService.getEventLog with parsed options', async () => { - const expected: ContractEventLogEntry[] = [{ - eventId: 'evt_1', - contractId: 'c1', - eventType: 'invoke', - payload: {}, - recordedAt: new Date(), - sequenceNumber: 1, - replayed: false, - }]; - contractsService.getEventLog.mockReturnValue(expected); - - const result = await controller.getEventLog('c1', 'true', '10'); - expect(result).toBe(expected); - expect(contractsService.getEventLog).toHaveBeenCalledWith('c1', { - onlyUnreplayed: true, - limit: 10, - }); - }); - - it('works without query params', async () => { - contractsService.getEventLog.mockReturnValue([]); - await controller.getEventLog('c1'); - expect(contractsService.getEventLog).toHaveBeenCalledWith('c1', { - onlyUnreplayed: false, - limit: undefined, - }); - }); - }); - - describe('POST contracts/:contractId/replay', () => { - it('delegates to contractsService.replayEvents and records metrics', async () => { - const expected: ReplayResult = { - replayId: 'replay_1', - contractId: 'c1', - eventsProcessed: 5, - eventsSucceeded: 5, - eventsFailed: 0, - status: 'completed' as const, - startedAt: new Date(), - completedAt: new Date(), - }; - contractsService.replayEvents.mockResolvedValue(expected); - - const result = await controller.replayEvents('c1', { maxEvents: 10 }); - expect(result).toBe(expected); - expect(metricsService.incrementCounter).toHaveBeenCalledWith( - 'contract_replays_total', - 1, - { contractId: 'c1', status: 'completed' }, - ); - }); - }); - - describe('GET contracts/:contractId/reconcile', () => { - it('delegates to contractsService.reconcileState and records metrics', async () => { - const expected: StateReconciliationResult = { - contractId: 'c1', - isConsistent: true, - eventLogCount: 5, - currentStateVersion: 5, - discrepancies: [], - reconciledAt: new Date(), - }; - contractsService.reconcileState.mockResolvedValue(expected); - - const result = await controller.reconcileState('c1'); - expect(result).toBe(expected); - expect(metricsService.incrementCounter).toHaveBeenCalledWith( - 'contract_reconciliations_total', - 1, - { contractId: 'c1', consistent: 'true' }, - ); - }); - }); - - describe('GET contracts/events/stats', () => { - it('delegates to contractsService.getEventLogStats', async () => { - const expected = { totalEvents: 10, unreplayedEvents: 3, eventsByContract: {} }; - contractsService.getEventLogStats.mockReturnValue(expected); - expect(await controller.getEventLogStats()).toBe(expected); - }); - }); - - describe('GET contracts/replay/history', () => { - it('delegates to contractsService.getReplayHistory', async () => { - const expected: ReplayResult[] = [{ - replayId: 'replay_1', - contractId: 'c1', - eventsProcessed: 5, - eventsSucceeded: 5, - eventsFailed: 0, - status: 'completed', - startedAt: new Date(), - completedAt: new Date(), - }]; - contractsService.getReplayHistory.mockReturnValue(expected); - - const result = await controller.getReplayHistory('c1'); - expect(result).toBe(expected); - expect(contractsService.getReplayHistory).toHaveBeenCalledWith('c1'); - }); - - it('works without contractId filter', async () => { - contractsService.getReplayHistory.mockReturnValue([]); - await controller.getReplayHistory(); - expect(contractsService.getReplayHistory).toHaveBeenCalledWith(undefined); - }); - }); - - // ========================================================================= - // #396: Adapter health - // ========================================================================= - - describe('GET contracts/adapter/health', () => { - it('delegates to contractsService.healthCheck', async () => { - const expected = { isHealthy: true, network: 'testnet', latency: 5 }; - contractsService.healthCheck.mockResolvedValue(expected); - expect(await controller.adapterHealth()).toBe(expected); - }); - }); -}); diff --git a/BackendAcademy/src/contracts/contracts.controller.ts b/BackendAcademy/src/contracts/contracts.controller.ts deleted file mode 100644 index ee50e4f6fb..0000000000 --- a/BackendAcademy/src/contracts/contracts.controller.ts +++ /dev/null @@ -1,284 +0,0 @@ -import { - Body, - Controller, - Delete, - Get, - Param, - Post, - Query, -} from '@nestjs/common'; -import { ContractsService } from './contracts.service'; -import { ContractRegistryService } from './contract-registry.service'; -import { MetricsService } from '../monitoring/metrics.service'; -import { - DeployContractDto, - InvokeContractDto, -} from './dto/invoke-contract.dto'; -import { CreateProposalDto, CastVoteDto } from './dto/governance.dto'; -import { - ContractRegistryEntry, - ContractRegistryFilter, - ReplayResult, - StateReconciliationResult, -} from './interfaces/contracts.interface'; - -/** - * Contracts controller with existing endpoints (reputation, certificates, - * badges, payouts, governance), gated contract invocation (#395), - * registry with schema checks (#393), event replay (#394), and - * adapter health (#396). - */ -@Controller('contracts') -export class ContractsController { - constructor( - private readonly contractsService: ContractsService, - private readonly registryService: ContractRegistryService, - private readonly metricsService: MetricsService, - ) {} - - // ────────────────────────────────────────────────────────────────── - // Reputation (existing) - // ────────────────────────────────────────────────────────────────── - - @Get('reputation/:userId') - getReputation(@Param('userId') userId: string) { - return this.contractsService.getReputation(userId); - } - - @Post('reputation/:userId') - updateReputation(@Param('userId') userId: string, @Body('score') score: number) { - return this.contractsService.updateReputation(userId, score); - } - - // ────────────────────────────────────────────────────────────────── - // Certificates (existing) - // ────────────────────────────────────────────────────────────────── - - @Post('certificates/issue') - issueCertificate(@Body('userId') userId: string, @Body('courseId') courseId: string) { - return this.contractsService.issueCertificate(userId, courseId); - } - - @Get('certificates/:id') - getCertificate(@Param('id') id: string) { - return this.contractsService.getCertificate(id); - } - - @Get('certificates/user/:userId') - listCertificates(@Param('userId') userId: string) { - return this.contractsService.listCertificates(userId); - } - - // ────────────────────────────────────────────────────────────────── - // Badges (existing) - // ────────────────────────────────────────────────────────────────── - - @Post('badges/issue') - issueBadge(@Body('userId') userId: string, @Body('badgeType') badgeType: string) { - return this.contractsService.issueBadge(userId, badgeType); - } - - @Get('badges/:id') - getBadge(@Param('id') id: string) { - return this.contractsService.getBadge(id); - } - - @Get('badges/user/:userId') - listBadges(@Param('userId') userId: string) { - return this.contractsService.listBadges(userId); - } - - // ────────────────────────────────────────────────────────────────── - // Payouts (existing) - // ────────────────────────────────────────────────────────────────── - - @Post('payouts/create') - createPayout( - @Body('userId') userId: string, - @Body('amount') amount: number, - @Body('currency') currency: string, - ) { - return this.contractsService.createPayout(userId, amount, currency); - } - - @Get('payouts/:id') - getPayout(@Param('id') id: string) { - return this.contractsService.getPayout(id); - } - - @Post('payouts/:id/release') - releasePayout(@Param('id') id: string) { - return this.contractsService.releasePayout(id); - } - - // ────────────────────────────────────────────────────────────────── - // Governance (existing, routed through service) - // ────────────────────────────────────────────────────────────────── - - @Post('governance/proposals') - createProposal(@Body() dto: CreateProposalDto) { - return this.contractsService.createProposal(dto.title, dto.description, dto.proposer); - } - - @Get('governance/proposals') - listProposals() { - return this.contractsService.listProposals(); - } - - @Get('governance/proposals/:id') - getProposal(@Param('id') id: string) { - return this.contractsService.getProposal(id); - } - - @Post('governance/proposals/:id/vote') - castVote(@Param('id') id: string, @Body() dto: CastVoteDto) { - return this.contractsService.castVote(id, dto.userId, dto.vote); - } - - // ────────────────────────────────────────────────────────────────── - // Contract deployment & invocation (gated by feature flags #395) - // ────────────────────────────────────────────────────────────────── - - @Post('invoke') - async invokeContract(@Body() dto: InvokeContractDto) { - const result = await this.contractsService.invokeContract(dto); - this.metricsService.incrementCounter('contract_invocations_total', 1, { - contractId: dto.contractId, - method: dto.method, - }); - return result; - } - - @Post('deploy') - async deployContract(@Body() dto: DeployContractDto) { - const result = await this.contractsService.deployContract(dto); - this.metricsService.incrementCounter('contract_deployments_total', 1, { - network: dto.network, - }); - return result; - } - - @Get(':contractId') - async getContractInfo(@Param('contractId') contractId: string) { - return this.contractsService.getContractInfo(contractId); - } - - @Get(':contractId/health') - async getContractHealth(@Param('contractId') contractId: string) { - return this.contractsService.getContractHealth(contractId); - } - - @Get(':contractId/history') - async getInvocationHistory(@Param('contractId') contractId: string) { - return this.contractsService.getInvocationHistory(contractId); - } - - @Get() - async getAllDeployments() { - return this.contractsService.getAllDeployments(); - } - - // ────────────────────────────────────────────────────────────────── - // #393: Contract registry with schema compatibility checks - // ────────────────────────────────────────────────────────────────── - - @Post('registry/register') - async registerContract( - @Body() - entry: Omit< - ContractRegistryEntry, - 'id' | 'registeredAt' | 'validatedAt' | 'validationStatus' - >, - ) { - const result = await this.registryService.register(entry); - this.metricsService.incrementCounter('contract_registry_entries_total', 1, { - network: entry.network, - status: result.validationStatus, - }); - return result; - } - - @Get('registry') - async listRegistry(@Query() filter?: ContractRegistryFilter) { - return { - entries: this.registryService.list(filter), - total: this.registryService.count, - }; - } - - @Get('registry/:contractId') - async getRegistryEntry(@Param('contractId') contractId: string) { - const entry = this.registryService.get(contractId); - if (!entry) { - return { error: 'CONTRACT_NOT_FOUND', message: `Contract ${contractId} not found in registry` }; - } - return entry; - } - - @Delete('registry/:contractId') - async deregisterContract(@Param('contractId') contractId: string) { - const removed = this.registryService.deregister(contractId); - if (removed) { - this.metricsService.incrementCounter('contract_registry_deregistrations_total', 1, { contractId }); - } - return { success: removed, contractId }; - } - - // ────────────────────────────────────────────────────────────────── - // #394: Event replay and state reconciliation - // ────────────────────────────────────────────────────────────────── - - @Get(':contractId/events') - async getEventLog( - @Param('contractId') contractId: string, - @Query('onlyUnreplayed') onlyUnreplayed?: string, - @Query('limit') limit?: string, - ) { - return this.contractsService.getEventLog(contractId, { - onlyUnreplayed: onlyUnreplayed === 'true', - limit: limit ? parseInt(limit, 10) : undefined, - }); - } - - @Post(':contractId/replay') - async replayEvents( - @Param('contractId') contractId: string, - @Body() options?: { fromSequence?: number; maxEvents?: number }, - ): Promise { - const result = await this.contractsService.replayEvents(contractId, options); - this.metricsService.incrementCounter('contract_replays_total', 1, { - contractId, - status: result.status, - }); - return result; - } - - @Get(':contractId/reconcile') - async reconcileState(@Param('contractId') contractId: string): Promise { - const result = await this.contractsService.reconcileState(contractId); - this.metricsService.incrementCounter('contract_reconciliations_total', 1, { - contractId, - consistent: String(result.isConsistent), - }); - return result; - } - - @Get('events/stats') - async getEventLogStats() { - return this.contractsService.getEventLogStats(); - } - - @Get('replay/history') - async getReplayHistory(@Query('contractId') contractId?: string) { - return this.contractsService.getReplayHistory(contractId); - } - - // ────────────────────────────────────────────────────────────────── - // #396: Adapter health check - // ────────────────────────────────────────────────────────────────── - - @Get('adapter/health') - async adapterHealth() { - return this.contractsService.healthCheck(); - } -} \ No newline at end of file diff --git a/BackendAcademy/src/contracts/contracts.module.ts b/BackendAcademy/src/contracts/contracts.module.ts deleted file mode 100644 index 211c4fbddb..0000000000 --- a/BackendAcademy/src/contracts/contracts.module.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ContractsController } from './contracts.controller'; -import { ContractsService } from './contracts.service'; -import { ContractRegistryService } from './contract-registry.service'; -import { MonitoringModule } from '../monitoring/monitoring.module'; - -/** - * Contracts module with feature-flagged ingestion (#395). - * - * The contract ingestion pipeline (invocation, deployment processing) - * is gated behind the CONTRACT_INGESTION_ENABLED feature flag checked - * at runtime in ContractsService. Registry and event replay are - * always available but their behavior adapts based on configuration. - * Monitoring metrics are tracked via MetricsService from MonitoringModule. - */ -@Module({ - imports: [MonitoringModule], - controllers: [ContractsController], - providers: [ContractsService, ContractRegistryService], - exports: [ContractsService, ContractRegistryService], -}) -export class ContractsModule {} \ No newline at end of file diff --git a/BackendAcademy/src/contracts/contracts.service.spec.ts b/BackendAcademy/src/contracts/contracts.service.spec.ts deleted file mode 100644 index e06ff376ac..0000000000 --- a/BackendAcademy/src/contracts/contracts.service.spec.ts +++ /dev/null @@ -1,901 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { BadRequestException, NotFoundException } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { ContractsService } from './contracts.service'; - -// --------------------------------------------------------------------------- -// Helper to create a valid source account (56-char string starting with G) -// --------------------------------------------------------------------------- -const validSourceAccount = 'G' + 'A'.repeat(55); -const validDeployer = 'G' + 'B'.repeat(55); - -describe('ContractsService', () => { - let service: ContractsService; - let configService: ConfigService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - ContractsService, - { - provide: ConfigService, - useValue: { - get: jest.fn((key: string, defaultValue?: unknown) => { - const overrides: Record = { - CONTRACT_INGESTION_ENABLED: 'true', - CONTRACT_EVENT_REPLAY_ENABLED: 'true', - CONTRACT_REPLAY_MAX_EVENTS: 1000, - CONTRACT_EVENT_RETENTION_DAYS: 90, - CONTRACT_NETWORK: 'testnet', - }; - return overrides[key] ?? defaultValue; - }), - }, - }, - ], - }).compile(); - - service = module.get(ContractsService); - configService = module.get(ConfigService); - }); - - // ========================================================================= - // Reputation - // ========================================================================= - - describe('getReputation(userId)', () => { - it('returns default reputation for unknown user', () => { - const rep = service.getReputation('unknown-user'); - expect(rep).toMatchObject({ userId: 'unknown-user', score: 0, level: 1 }); - expect(rep.lastUpdated).toBeInstanceOf(Date); - }); - - it('returns updated reputation after updateReputation', () => { - service.updateReputation('user1', 350); - const rep = service.getReputation('user1'); - expect(rep.score).toBe(350); - expect(rep.level).toBe(4); // floor(350/100) + 1 - }); - }); - - describe('updateReputation(userId, score)', () => { - it('returns success with data', () => { - const result = service.updateReputation('user1', 500); - expect(result.success).toBe(true); - expect(result.data.score).toBe(500); - }); - - it('level is computed from score', () => { - const result = service.updateReputation('user2', 0); - expect(result.data.level).toBe(1); - - const result2 = service.updateReputation('user2', 999); - expect(result2.data.level).toBe(10); // floor(999/100) + 1 - }); - }); - - // ========================================================================= - // Certificates - // ========================================================================= - - describe('issueCertificate(userId, courseId)', () => { - it('issues a certificate with auto-generated id', () => { - const result = service.issueCertificate('user1', 'course-rust-101'); - expect(result.success).toBe(true); - expect(result.data.userId).toBe('user1'); - expect(result.data.courseId).toBe('course-rust-101'); - expect(result.data.id).toMatch(/^cert_/); - expect(result.data.issuedAt).toBeInstanceOf(Date); - }); - }); - - describe('getCertificate(id)', () => { - it('returns an issued certificate', () => { - const { data } = service.issueCertificate('user1', 'course-1'); - const cert = service.getCertificate(data.id); - expect(cert.id).toBe(data.id); - }); - - it('throws NotFoundException for unknown id', () => { - expect(() => service.getCertificate('cert_nonexistent')).toThrow( - NotFoundException, - ); - }); - }); - - describe('listCertificates(userId)', () => { - it('returns certificates for a user', () => { - service.issueCertificate('alice', 'c1'); - service.issueCertificate('alice', 'c2'); - service.issueCertificate('bob', 'c3'); - - const aliceCerts = service.listCertificates('alice'); - expect(aliceCerts).toHaveLength(2); - expect(aliceCerts.every((c) => c.userId === 'alice')).toBe(true); - }); - - it('returns empty array for user with no certificates', () => { - expect(service.listCertificates('nobody')).toEqual([]); - }); - }); - - // ========================================================================= - // Badges - // ========================================================================= - - describe('issueBadge(userId, badgeType)', () => { - it('issues a badge with auto-generated id', () => { - const result = service.issueBadge('user1', 'streak-master'); - expect(result.success).toBe(true); - expect(result.data.userId).toBe('user1'); - expect(result.data.badgeType).toBe('streak-master'); - expect(result.data.id).toMatch(/^badge_/); - expect(result.data.issuedAt).toBeInstanceOf(Date); - }); - }); - - describe('getBadge(id)', () => { - it('returns an issued badge', () => { - const { data } = service.issueBadge('user1', 'early-adopter'); - const badge = service.getBadge(data.id); - expect(badge.id).toBe(data.id); - }); - - it('throws NotFoundException for unknown id', () => { - expect(() => service.getBadge('badge_nonexistent')).toThrow( - NotFoundException, - ); - }); - }); - - describe('listBadges(userId)', () => { - it('returns badges for a user', () => { - service.issueBadge('alice', 'contributor'); - service.issueBadge('alice', 'streak-master'); - service.issueBadge('bob', 'early-adopter'); - - const aliceBadges = service.listBadges('alice'); - expect(aliceBadges).toHaveLength(2); - expect(aliceBadges.every((b) => b.userId === 'alice')).toBe(true); - }); - - it('returns empty array for user with no badges', () => { - expect(service.listBadges('nobody')).toEqual([]); - }); - }); - - // ========================================================================= - // Payouts - // ========================================================================= - - describe('createPayout(userId, amount, currency)', () => { - it('creates a pending payout', () => { - const result = service.createPayout('user1', 100, 'XLM'); - expect(result.success).toBe(true); - expect(result.data.status).toBe('pending'); - expect(result.data.amount).toBe(100); - expect(result.data.currency).toBe('XLM'); - expect(result.data.id).toMatch(/^payout_/); - }); - }); - - describe('getPayout(id)', () => { - it('returns a created payout', () => { - const { data } = service.createPayout('user1', 50, 'USDC'); - const payout = service.getPayout(data.id); - expect(payout.id).toBe(data.id); - }); - - it('throws NotFoundException for unknown id', () => { - expect(() => service.getPayout('payout_nonexistent')).toThrow( - NotFoundException, - ); - }); - }); - - describe('releasePayout(id)', () => { - it('transitions payout status to completed', () => { - const { data } = service.createPayout('user1', 50, 'XLM'); - const result = service.releasePayout(data.id); - expect(result.success).toBe(true); - expect(result.data.status).toBe('completed'); - }); - - it('throws NotFoundException for unknown id', () => { - expect(() => service.releasePayout('payout_nonexistent')).toThrow( - NotFoundException, - ); - }); - }); - - // ========================================================================= - // Governance - // ========================================================================= - - describe('createProposal(title, description, proposer)', () => { - it('creates an active proposal', () => { - const result = service.createProposal('Increase rewards', 'Double XP for all', 'user1'); - expect(result.success).toBe(true); - expect(result.data.status).toBe('active'); - expect(result.data.yesVotes).toBe(0); - expect(result.data.noVotes).toBe(0); - expect(result.data.id).toMatch(/^prop_/); - }); - }); - - describe('getProposal(id)', () => { - it('returns a created proposal', () => { - const { data } = service.createProposal('Title', 'Desc', 'user1'); - const prop = service.getProposal(data.id); - expect(prop.id).toBe(data.id); - }); - - it('throws NotFoundException for unknown id', () => { - expect(() => service.getProposal('prop_nonexistent')).toThrow( - NotFoundException, - ); - }); - }); - - describe('listProposals()', () => { - it('returns all proposals', () => { - service.createProposal('P1', 'Desc 1', 'user1'); - service.createProposal('P2', 'Desc 2', 'user2'); - expect(service.listProposals()).toHaveLength(2); - }); - - it('returns empty array when no proposals exist', () => { - // Fresh service from beforeEach already has none - expect(service.listProposals()).toEqual([]); - }); - }); - - describe('castVote(proposalId, userId, vote)', () => { - it('casts a yes vote', () => { - const { data } = service.createProposal('Title', 'Desc', 'user1'); - const result = service.castVote(data.id, 'voter1', 'yes'); - expect(result.success).toBe(true); - expect(result.data!.yesVotes).toBe(1); - }); - - it('casts a no vote', () => { - const { data } = service.createProposal('Title', 'Desc', 'user1'); - const result = service.castVote(data.id, 'voter1', 'no'); - expect(result.data!.noVotes).toBe(1); - }); - - it('throws NotFoundException for unknown proposal', () => { - expect(() => - service.castVote('prop_nonexistent', 'voter1', 'yes'), - ).toThrow(NotFoundException); - }); - }); - - // ========================================================================= - // Contract deployment - // ========================================================================= - - describe('deployContract(dto)', () => { - it('deploys a contract successfully', async () => { - const result = await service.deployContract({ - contractId: 'CRUSTDEFIABCDEFGHIJKLMNOPQRSTUVWXYZ12', - wasmHash: 'a'.repeat(64), - deployedBy: validDeployer, - network: 'testnet', - }); - - expect(result.contractId).toBe('CRUSTDEFIABCDEFGHIJKLMNOPQRSTUVWXYZ12'); - expect(result.wasmHash).toBe('a'.repeat(64)); - expect(result.network).toBe('testnet'); - expect(result.deployedAt).toBeInstanceOf(Date); - }); - - it('throws on duplicate deployment', async () => { - const dto = { - contractId: 'CRUSTDEFIABCDEFGHIJKLMNOPQRSTUVWXYZ99', - wasmHash: 'b'.repeat(64), - deployedBy: validDeployer, - network: 'testnet', - }; - await service.deployContract(dto); - await expect(service.deployContract(dto)).rejects.toThrow( - BadRequestException, - ); - }); - - it('throws on invalid contractId (empty)', async () => { - await expect( - service.deployContract({ - contractId: '', - wasmHash: 'a'.repeat(64), - deployedBy: validDeployer, - network: 'testnet', - }), - ).rejects.toThrow(BadRequestException); - }); - }); - - // ========================================================================= - // Contract invocation - // ========================================================================= - - describe('invokeContract(dto)', () => { - const contractId = 'CRUSTDEFIABCDEFGHIJKLMNOPQRSTUVWXYZ42'; - - beforeEach(async () => { - await service.deployContract({ - contractId, - wasmHash: 'c'.repeat(64), - deployedBy: validDeployer, - network: 'testnet', - }); - // Make simulateInvocation deterministic: always succeed - jest.spyOn(Math, 'random').mockReturnValue(0.1); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('invokes a contract method successfully', async () => { - const result = await service.invokeContract({ - contractId, - method: 'transfer', - args: ['alice', 'bob', '100'], - sourceAccount: validSourceAccount, - }); - - expect(result.success).toBe(true); - expect(result.contractId).toBe(contractId); - expect(result.method).toBe('transfer'); - expect(result.invocationId).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, - ); - expect(result.transactionHash).toHaveLength(64); - expect(result.executedAt).toBeInstanceOf(Date); - }); - - it('throws when contract is not deployed', async () => { - await expect( - service.invokeContract({ - contractId: 'CRUSTDEFIABCDEFGHIJKLMNOPQRSTUVWXYZ99', - method: 'balance', - args: [], - sourceAccount: validSourceAccount, - }), - ).rejects.toThrow(BadRequestException); - }); - - it('throws on invalid source account (wrong prefix)', async () => { - await expect( - service.invokeContract({ - contractId, - method: 'balance', - args: [], - sourceAccount: 'X' + 'A'.repeat(55), - }), - ).rejects.toThrow(BadRequestException); - }); - - it('throws on invalid source account (wrong length)', async () => { - await expect( - service.invokeContract({ - contractId, - method: 'balance', - args: [], - sourceAccount: 'GABC', - }), - ).rejects.toThrow(BadRequestException); - }); - - it('throws on missing source account', async () => { - await expect( - service.invokeContract({ - contractId, - method: 'balance', - args: [], - sourceAccount: '', - }), - ).rejects.toThrow(BadRequestException); - }); - - it('throws on missing contractId', async () => { - await expect( - service.invokeContract({ - contractId: '', - method: 'balance', - args: [], - sourceAccount: validSourceAccount, - }), - ).rejects.toThrow(BadRequestException); - }); - - it('balance method returns token amount', async () => { - const result = await service.invokeContract({ - contractId, - method: 'balance', - args: [], - sourceAccount: validSourceAccount, - }); - - expect(result.success).toBe(true); - const val = result.result as { amount: string; token: string }; - expect(val.token).toBe('XLM'); - expect(parseFloat(val.amount)).toBeGreaterThan(0); - }); - - it('allowance method returns owner/spender/amount', async () => { - const result = await service.invokeContract({ - contractId, - method: 'allowance', - args: ['owner1', 'spender1'], - sourceAccount: validSourceAccount, - }); - - expect(result.success).toBe(true); - const val = result.result as { owner: string; spender: string; amount: string }; - expect(val.owner).toBe('owner1'); - expect(val.spender).toBe('spender1'); - }); - }); - - // ========================================================================= - // getContractInfo / getContractHealth / getInvocationHistory / deployments - // ========================================================================= - - describe('getContractInfo(contractId)', () => { - it('returns contract info for deployed contract', async () => { - const contractId = 'CRUSTDEFIABCDEFGHIJKLMNOPQRSTUVWXYZ77'; - await service.deployContract({ - contractId, - wasmHash: 'd'.repeat(64), - deployedBy: validDeployer, - network: 'futurenet', - }); - - const info = await service.getContractInfo(contractId); - expect(info.contractId).toBe(contractId); - expect(info.network).toBe('futurenet'); - expect(info.methods).toContain('transfer'); - expect(info.methods).toContain('balance'); - }); - - it('throws on non-existent contract', async () => { - await expect( - service.getContractInfo('CRUSTDEFIABCDEFGHIJKLMNOPQRSTUVWXYZ99'), - ).rejects.toThrow(BadRequestException); - }); - }); - - describe('getContractHealth(contractId)', () => { - it('returns inactive for never-invoked contract', async () => { - const contractId = 'CRUSTDEFIABCDEFGHIJKLMNOPQRSTUVWXYZ88'; - await service.deployContract({ - contractId, - wasmHash: 'e'.repeat(64), - deployedBy: validDeployer, - network: 'testnet', - }); - - const health = await service.getContractHealth(contractId); - expect(health.status).toBe('inactive'); - expect(health.invocationCount).toBe(0); - }); - - it('returns active after invocation', async () => { - const contractId = 'CRUSTDEFIABCDEFGHIJKLMNOPQRSTUVWXYZ55'; - await service.deployContract({ - contractId, - wasmHash: 'f'.repeat(64), - deployedBy: validDeployer, - network: 'testnet', - }); - await service.invokeContract({ - contractId, - method: 'balance', - args: [], - sourceAccount: validSourceAccount, - }); - - const health = await service.getContractHealth(contractId); - expect(health.status).toBe('active'); - expect(health.invocationCount).toBe(1); - }); - }); - - describe('getInvocationHistory(contractId)', () => { - it('returns empty array for no invocations', async () => { - const history = await service.getInvocationHistory('CRUSTDEFIABCDEFGHIJKLMNOPQRSTUVWXYZ00'); - expect(history).toEqual([]); - }); - - it('returns invocations in order', async () => { - const contractId = 'CRUSTDEFIABCDEFGHIJKLMNOPQRSTUVWXYZ33'; - await service.deployContract({ - contractId, - wasmHash: 'g'.repeat(64), - deployedBy: validDeployer, - network: 'testnet', - }); - await service.invokeContract({ - contractId, - method: 'balance', - args: [], - sourceAccount: validSourceAccount, - }); - await service.invokeContract({ - contractId, - method: 'transfer', - args: ['a', 'b', '10'], - sourceAccount: validSourceAccount, - }); - - const history = await service.getInvocationHistory(contractId); - expect(history).toHaveLength(2); - expect(history[0].method).toBe('balance'); - expect(history[1].method).toBe('transfer'); - }); - }); - - describe('getAllDeployments()', () => { - it('returns all deployments', async () => { - await service.deployContract({ - contractId: 'CRUSTDEFIABCDEFGHIJKLMNOPQRSTUVWXYZ11', - wasmHash: 'h'.repeat(64), - deployedBy: validDeployer, - network: 'testnet', - }); - await service.deployContract({ - contractId: 'CRUSTDEFIABCDEFGHIJKLMNOPQRSTUVWXYZ22', - wasmHash: 'i'.repeat(64), - deployedBy: validDeployer, - network: 'futurenet', - }); - - const deployments = await service.getAllDeployments(); - expect(deployments).toHaveLength(2); - }); - }); - - // ========================================================================= - // #394: Event log - // ========================================================================= - - describe('getEventLog(contractId, options?)', () => { - it('returns events matching contractId', async () => { - const contractId = 'CRUSTDEFIABCDEFGHIJKLMNOPQRSTUVWXYZ66'; - await service.deployContract({ - contractId, - wasmHash: 'j'.repeat(64), - deployedBy: validDeployer, - network: 'testnet', - }); - - const events = service.getEventLog(contractId); - expect(events.length).toBeGreaterThan(0); - expect(events[0].contractId).toBe(contractId); - expect(events[0].eventType).toBe('deploy'); - }); - - it('filters only unreplayed events', async () => { - const contractId = 'CRUSTDEFIABCDEFGHIJKLMNOPQRSTUVWXYZ44'; - await service.deployContract({ - contractId, - wasmHash: 'k'.repeat(64), - deployedBy: validDeployer, - network: 'testnet', - }); - - // All events start as unreplayed - const unreplayed = service.getEventLog(contractId, { onlyUnreplayed: true }); - expect(unreplayed.length).toBeGreaterThan(0); - expect(unreplayed.every((e) => !e.replayed)).toBe(true); - }); - - it('respects limit option', async () => { - const contractId = 'CRUSTDEFIABCDEFGHIJKLMNOPQRSTUVWXYZ09'; - await service.deployContract({ - contractId, - wasmHash: 'l'.repeat(64), - deployedBy: validDeployer, - network: 'testnet', - }); - // Invoke several times to create multiple events - for (let i = 0; i < 5; i++) { - await service.invokeContract({ - contractId, - method: 'balance', - args: [], - sourceAccount: validSourceAccount, - }); - } - - const events = service.getEventLog(contractId, { limit: 2 }); - expect(events).toHaveLength(2); - }); - }); - - // ========================================================================= - // #394: Event replay - // ========================================================================= - - describe('replayEvents(contractId, options?)', () => { - it('replays events and marks them as replayed', async () => { - const contractId = 'CRUSTDEFIABCDEFGHIJKLMNOPQRSTUVWXYZ08'; - await service.deployContract({ - contractId, - wasmHash: 'm'.repeat(64), - deployedBy: validDeployer, - network: 'testnet', - }); - - const result = await service.replayEvents(contractId); - expect(result.status).toBe('completed'); - expect(result.eventsProcessed).toBeGreaterThan(0); - expect(result.eventsSucceeded).toBe(result.eventsProcessed); - expect(result.eventsFailed).toBe(0); - expect(result.replayId).toMatch(/^replay_/); - }); - - it('returns empty result for contract with no events', async () => { - const result = await service.replayEvents('CRUSTDEFIABCDEFGHIJKLMNOPQRSTUVWXYZ00'); - expect(result.eventsProcessed).toBe(0); - expect(result.status).toBe('completed'); - }); - - it('respects maxEvents option', async () => { - const contractId = 'CRUSTDEFIABCDEFGHIJKLMNOPQRSTUVWXYZ07'; - await service.deployContract({ - contractId, - wasmHash: 'n'.repeat(64), - deployedBy: validDeployer, - network: 'testnet', - }); - for (let i = 0; i < 5; i++) { - await service.invokeContract({ - contractId, - method: 'balance', - args: [], - sourceAccount: validSourceAccount, - }); - } - - const result = await service.replayEvents(contractId, { maxEvents: 2 }); - expect(result.eventsProcessed).toBeLessThanOrEqual(2); - }); - }); - - // ========================================================================= - // #394: State reconciliation - // ========================================================================= - - describe('reconcileState(contractId)', () => { - it('returns consistent state for deployed contract', async () => { - const contractId = 'CRUSTDEFIABCDEFGHIJKLMNOPQRSTUVWXYZ06'; - await service.deployContract({ - contractId, - wasmHash: 'o'.repeat(64), - deployedBy: validDeployer, - network: 'testnet', - }); - - const result = await service.reconcileState(contractId); - expect(result.contractId).toBe(contractId); - expect(result.isConsistent).toBe(true); - expect(result.reconciledAt).toBeInstanceOf(Date); - }); - - it('reports critical discrepancy for non-existent contract', async () => { - const result = await service.reconcileState('CRUSTDEFIABCDEFGHIJKLMNOPQRSTUVWXYZ99'); - expect(result.isConsistent).toBe(false); - expect(result.discrepancies.some((d) => d.severity === 'critical')).toBe(true); - }); - - it('reports warning for invocation count mismatch (after some invocations)', async () => { - const contractId = 'CRUSTDEFIABCDEFGHIJKLMNOPQRSTUVWXYZ05'; - await service.deployContract({ - contractId, - wasmHash: 'p'.repeat(64), - deployedBy: validDeployer, - network: 'testnet', - }); - await service.invokeContract({ - contractId, - method: 'balance', - args: [], - sourceAccount: validSourceAccount, - }); - - const result = await service.reconcileState(contractId); - // After invocation, counts should match (1 event, 1 count) - expect(result.isConsistent).toBe(true); - }); - }); - - // ========================================================================= - // #394: Replay history & stats - // ========================================================================= - - describe('getReplayHistory(contractId?)', () => { - it('returns empty array when no replays exist', () => { - expect(service.getReplayHistory()).toEqual([]); - }); - - it('returns replay records after replayEvents', async () => { - const contractId = 'CRUSTDEFIABCDEFGHIJKLMNOPQRSTUVWXYZ04'; - await service.deployContract({ - contractId, - wasmHash: 'q'.repeat(64), - deployedBy: validDeployer, - network: 'testnet', - }); - await service.replayEvents(contractId); - - const history = service.getReplayHistory(); - expect(history.length).toBeGreaterThan(0); - }); - - it('filters by contractId', async () => { - const history = service.getReplayHistory('CRUSTDEFIABCDEFGHIJKLMNOPQRSTUVWXYZ00'); - expect(history).toEqual([]); - }); - }); - - describe('getEventLogStats()', () => { - it('returns zero totals when no events', () => { - const stats = service.getEventLogStats(); - expect(stats.totalEvents).toBe(0); - expect(stats.unreplayedEvents).toBe(0); - }); - - it('returns correct totals after deployment and invocation', async () => { - const contractId = 'CRUSTDEFIABCDEFGHIJKLMNOPQRSTUVWXYZ03'; - await service.deployContract({ - contractId, - wasmHash: 'r'.repeat(64), - deployedBy: validDeployer, - network: 'testnet', - }); - - const stats = service.getEventLogStats(); - expect(stats.totalEvents).toBeGreaterThan(0); - expect(stats.eventsByContract[contractId]).toBeDefined(); - }); - }); - - // ========================================================================= - // #396: IContractAdapter methods - // ========================================================================= - - describe('recordReward(userId, amount, reason)', () => { - it('returns transaction hash and block number', async () => { - const result = await service.recordReward('user1', 100, 'course-completion'); - expect(result.transactionHash).toHaveLength(64); - expect(result.blockNumber).toBeGreaterThan(0); - }); - }); - - describe('recordPayment(fromUserId, toUserId, amount, currency, memo?)', () => { - it('records a payment between two users', async () => { - const result = await service.recordPayment('alice', 'bob', 50, 'XLM', 'thanks!'); - expect(result.transactionHash).toHaveLength(64); - expect(result.timestamp).toBeInstanceOf(Date); - }); - - it('works without a memo', async () => { - const result = await service.recordPayment('alice', 'bob', 50, 'USDC'); - expect(result.transactionHash).toHaveLength(64); - }); - }); - - describe('mintCertificate(userId, courseId, metadata)', () => { - it('returns tokenId and transaction hash', async () => { - const result = await service.mintCertificate('user1', 'rust-101', { grade: 'A' }); - expect(result.tokenId).toMatch(/^cert_/); - expect(result.transactionHash).toHaveLength(64); - }); - }); - - describe('mintBadge(userId, badgeType, metadata)', () => { - it('returns tokenId and transaction hash', async () => { - const result = await service.mintBadge('user1', 'streak-master', { - streakDays: 30, - }); - expect(result.tokenId).toMatch(/^badge_/); - expect(result.transactionHash).toHaveLength(64); - }); - }); - - describe('healthCheck()', () => { - it('returns healthy when ingestion is enabled', async () => { - const result = await service.healthCheck(); - expect(result.isHealthy).toBe(true); - expect(result.network).toBe('testnet'); - expect(result.latency).toBeGreaterThanOrEqual(0); - }); - }); - - // ========================================================================= - // Feature flag: ingestion disabled - // ========================================================================= - - describe('when CONTRACT_INGESTION_ENABLED is false', () => { - let disabledService: ContractsService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - ContractsService, - { - provide: ConfigService, - useValue: { - get: jest.fn((key: string, defaultValue?: unknown) => { - const overrides: Record = { - CONTRACT_INGESTION_ENABLED: 'false', - CONTRACT_EVENT_REPLAY_ENABLED: 'true', - CONTRACT_NETWORK: 'testnet', - }; - return overrides[key] ?? defaultValue; - }), - }, - }, - ], - }).compile(); - - disabledService = module.get(ContractsService); - }); - - it('throws when invoking a contract', async () => { - await expect( - disabledService.invokeContract({ - contractId: 'CRUSTDEFIABCDEFGHIJKLMNOPQRSTUVWXYZ01', - method: 'balance', - args: [], - sourceAccount: validSourceAccount, - }), - ).rejects.toThrow(BadRequestException); - }); - - it('healthCheck reports unhealthy', async () => { - const result = await disabledService.healthCheck(); - expect(result.isHealthy).toBe(false); - }); - }); - - // ========================================================================= - // Feature flag: replay disabled - // ========================================================================= - - describe('when CONTRACT_EVENT_REPLAY_ENABLED is false', () => { - let disabledService: ContractsService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - ContractsService, - { - provide: ConfigService, - useValue: { - get: jest.fn((key: string, defaultValue?: unknown) => { - const overrides: Record = { - CONTRACT_INGESTION_ENABLED: 'true', - CONTRACT_EVENT_REPLAY_ENABLED: 'false', - CONTRACT_NETWORK: 'testnet', - }; - return overrides[key] ?? defaultValue; - }), - }, - }, - ], - }).compile(); - - disabledService = module.get(ContractsService); - }); - - it('throws when replaying events', async () => { - await expect( - disabledService.replayEvents('CRUSTDEFIABCDEFGHIJKLMNOPQRSTUVWXYZ01'), - ).rejects.toThrow(BadRequestException); - }); - }); -}); diff --git a/BackendAcademy/src/contracts/contracts.service.ts b/BackendAcademy/src/contracts/contracts.service.ts deleted file mode 100644 index 001702690d..0000000000 --- a/BackendAcademy/src/contracts/contracts.service.ts +++ /dev/null @@ -1,575 +0,0 @@ -import { - BadRequestException, - Inject, - Injectable, - Logger, - NotFoundException, - Optional, -} from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { v4 as uuidv4 } from 'uuid'; -import { InvokeContractDto, DeployContractDto } from './dto/invoke-contract.dto'; -import { - ContractDeployment, - ContractHealth, - ContractInfo, - ContractInvocationResult, - ContractEventLogEntry, - ReplayResult, - StateReconciliationResult, - StateDiscrepancy, - IContractAdapter, -} from './interfaces/contracts.interface'; -import { isFeatureEnabled } from '../config/env.schema'; - -/** - * Contracts service providing: - * - Contract deployment & invocation (existing) - * - Reputation, certificates, badges, payouts, governance (existing) - * - Event log for replay (#394) - * - State reconciliation (#394) - * - Adapter boundary for isolating contract integration (#396) - */ -@Injectable() -export class ContractsService implements IContractAdapter { - private readonly logger = new Logger(ContractsService.name); - private readonly deployments = new Map(); - private readonly invocationHistory = new Map(); - private readonly invocationCounts = new Map(); - - // ── Existing domain stores ───────────────────────────────────── - private readonly reputations = new Map(); - private readonly certificates = new Map(); - private readonly badges = new Map(); - private readonly payouts = new Map(); - private readonly proposals = new Map(); - - // ── #394: Event log for replay ────────────────────────────────── - private readonly eventLog: ContractEventLogEntry[] = []; - private eventSequenceCounter = 0; - - // ── Feature flags ────────────────────────────────────────────── - private readonly replayEnabled: boolean; - private readonly ingestionEnabled: boolean; - - constructor(private readonly configService: ConfigService) { - this.replayEnabled = isFeatureEnabled( - this.configService.get('CONTRACT_EVENT_REPLAY_ENABLED'), - ); - this.ingestionEnabled = isFeatureEnabled( - this.configService.get('CONTRACT_INGESTION_ENABLED'), - ); - } - - // ────────────────────────────────────────────────────────────────── - // Reputation - // ────────────────────────────────────────────────────────────────── - - getReputation(userId: string) { - return this.reputations.get(userId) ?? { userId, score: 0, level: 1, lastUpdated: new Date() }; - } - - updateReputation(userId: string, score: number) { - const record = { userId, score, level: Math.floor(score / 100) + 1, lastUpdated: new Date() }; - this.reputations.set(userId, record); - return { success: true, data: record }; - } - - // ────────────────────────────────────────────────────────────────── - // Certificates - // ────────────────────────────────────────────────────────────────── - - issueCertificate(userId: string, courseId: string) { - const cert = { id: `cert_${uuidv4()}`, userId, courseId, issuedAt: new Date() }; - this.certificates.set(cert.id, cert); - return { success: true, data: cert }; - } - - getCertificate(id: string) { - const cert = this.certificates.get(id); - if (!cert) throw new NotFoundException('Certificate not found'); - return cert; - } - - listCertificates(userId: string) { - return Array.from(this.certificates.values()).filter((c) => c.userId === userId); - } - - // ────────────────────────────────────────────────────────────────── - // Badges - // ────────────────────────────────────────────────────────────────── - - issueBadge(userId: string, badgeType: string) { - const badge = { id: `badge_${uuidv4()}`, userId, badgeType, issuedAt: new Date() }; - this.badges.set(badge.id, badge); - return { success: true, data: badge }; - } - - getBadge(id: string) { - const badge = this.badges.get(id); - if (!badge) throw new NotFoundException('Badge not found'); - return badge; - } - - listBadges(userId: string) { - return Array.from(this.badges.values()).filter((b) => b.userId === userId); - } - - // ────────────────────────────────────────────────────────────────── - // Payouts - // ────────────────────────────────────────────────────────────────── - - createPayout(userId: string, amount: number, currency: string) { - const payout = { - id: `payout_${uuidv4()}`, userId, amount, currency, - status: 'pending' as const, createdAt: new Date(), - }; - this.payouts.set(payout.id, payout); - return { success: true, data: payout }; - } - - getPayout(id: string) { - const payout = this.payouts.get(id); - if (!payout) throw new NotFoundException('Payout not found'); - return payout; - } - - releasePayout(id: string) { - const payout = this.payouts.get(id); - if (!payout) throw new NotFoundException('Payout not found'); - payout.status = 'completed'; - return { success: true, data: payout }; - } - - // ────────────────────────────────────────────────────────────────── - // Governance - // ────────────────────────────────────────────────────────────────── - - createProposal(title: string, description: string, proposer: string) { - const proposal = { - id: `prop_${uuidv4()}`, title, description, proposer, - yesVotes: 0, noVotes: 0, - status: 'active' as const, createdAt: new Date(), - }; - this.proposals.set(proposal.id, proposal); - return { success: true, message: 'Proposal created', data: proposal }; - } - - getProposal(id: string) { - const proposal = this.proposals.get(id); - if (!proposal) throw new NotFoundException('Proposal not found'); - return proposal; - } - - listProposals() { - return Array.from(this.proposals.values()); - } - - castVote(proposalId: string, userId: string, vote: 'yes' | 'no') { - const proposal = this.proposals.get(proposalId); - if (!proposal) throw new NotFoundException('Proposal not found'); - if (proposal.status !== 'active') { - return { success: false, message: 'Proposal is no longer active' }; - } - if (vote === 'yes') proposal.yesVotes++; - else proposal.noVotes++; - return { success: true, message: `Vote cast as ${vote}`, data: proposal }; - } - - // ────────────────────────────────────────────────────────────────── - // Contract invocation & deployment - // ────────────────────────────────────────────────────────────────── - - async invokeContract(dto: InvokeContractDto): Promise { - this.validateContractId(dto.contractId); - this.validateSourceAccount(dto.sourceAccount); - - if (!this.ingestionEnabled) { - throw new BadRequestException({ - error: 'CONTRACT_INGESTION_DISABLED', - message: - 'Contract ingestion is disabled. Set CONTRACT_INGESTION_ENABLED=true to enable.', - }); - } - - const deployment = this.deployments.get(dto.contractId); - if (!deployment) { - throw new BadRequestException({ - error: 'CONTRACT_NOT_DEPLOYED', - message: `Contract ${dto.contractId} has not been deployed yet`, - }); - } - - const invocationId = uuidv4(); - const txHash = this.generateTransactionHash(); - const result = this.simulateInvocation(dto.method, dto.args); - - const invocationResult: ContractInvocationResult = { - invocationId, - contractId: dto.contractId, - method: dto.method, - success: result.success, - result: result.value, - error: result.error, - transactionHash: txHash, - executedAt: new Date(), - }; - - this.recordInvocation(dto.contractId, invocationResult); - this.appendEventLog(dto.contractId, dto.method, { - args: dto.args, - sourceAccount: dto.sourceAccount, - success: result.success, - txHash, - result: result.value, - }); - - return invocationResult; - } - - async deployContract(dto: DeployContractDto): Promise { - this.validateContractId(dto.contractId); - - if (this.deployments.has(dto.contractId)) { - throw new BadRequestException({ - error: 'CONTRACT_ALREADY_DEPLOYED', - message: `Contract ${dto.contractId} is already deployed`, - }); - } - - const deployment: ContractDeployment = { - contractId: dto.contractId, - wasmHash: dto.wasmHash, - deployedAt: new Date(), - deployedBy: dto.deployedBy, - network: dto.network as ContractDeployment['network'], - }; - - this.deployments.set(dto.contractId, deployment); - this.appendEventLog(dto.contractId, 'deploy', { - wasmHash: dto.wasmHash, - deployedBy: dto.deployedBy, - network: dto.network, - }); - - return deployment; - } - - async getContractInfo(contractId: string): Promise { - const deployment = this.deployments.get(contractId); - if (!deployment) { - throw new BadRequestException({ - error: 'CONTRACT_NOT_FOUND', - message: `Contract ${contractId} not found`, - }); - } - - return { - contractId: deployment.contractId, - wasmHash: deployment.wasmHash, - network: deployment.network, - deployedBy: deployment.deployedBy, - deployedAt: deployment.deployedAt, - methods: ['transfer', 'balance', 'approve', 'burn', 'mint', 'allowance'], - }; - } - - async getContractHealth(contractId: string): Promise { - const count = this.invocationCounts.get(contractId) ?? 0; - const history = this.invocationHistory.get(contractId) ?? []; - const lastInvokedAt = history.length > 0 ? history[history.length - 1].executedAt : undefined; - - let status: ContractHealth['status'] = 'active'; - if (count === 0) { - status = 'inactive'; - } else if (history.length > 0) { - const last = history[history.length - 1]; - const hoursSinceLastInvocation = - (Date.now() - last.executedAt.getTime()) / (1000 * 60 * 60); - if (hoursSinceLastInvocation > 24) { - status = 'degraded'; - } - } - - return { - contractId, - status, - lastInvokedAt, - invocationCount: count, - network: this.deployments.get(contractId)?.network ?? 'testnet', - }; - } - - async getInvocationHistory(contractId: string): Promise { - return this.invocationHistory.get(contractId) ?? []; - } - - async getAllDeployments(): Promise { - return Array.from(this.deployments.values()); - } - - // ────────────────────────────────────────────────────────────────── - // #394: Event replay and state reconciliation - // ────────────────────────────────────────────────────────────────── - - getEventLog( - contractId: string, - options?: { onlyUnreplayed?: boolean; limit?: number }, - ): ContractEventLogEntry[] { - let events = this.eventLog.filter((e) => e.contractId === contractId); - if (options?.onlyUnreplayed) { - events = events.filter((e) => !e.replayed); - } - events.sort((a, b) => a.sequenceNumber - b.sequenceNumber); - if (options?.limit && options.limit > 0) { - events = events.slice(0, options.limit); - } - return events; - } - - async replayEvents( - contractId: string, - options?: { fromSequence?: number; maxEvents?: number }, - ): Promise { - if (!this.replayEnabled) { - throw new BadRequestException({ - error: 'EVENT_REPLAY_DISABLED', - message: 'Contract event replay is disabled. Set CONTRACT_EVENT_REPLAY_ENABLED=true to enable.', - }); - } - - const maxEvents = options?.maxEvents ?? this.configService.get('CONTRACT_REPLAY_MAX_EVENTS', 1000); - let events = this.eventLog.filter((e) => e.contractId === contractId); - if (options?.fromSequence !== undefined) { - events = events.filter((e) => e.sequenceNumber >= options!.fromSequence!); - } - events = events.slice(0, maxEvents); - - const replayId = `replay_${uuidv4()}`; - const startedAt = new Date(); - let succeeded = 0; - let failed = 0; - const errors: Array<{ eventId: string; error: string }> = []; - - for (const event of events) { - try { - this.logger.debug(`Replaying event ${event.eventId} (seq=${event.sequenceNumber})`); - event.replayed = true; - event.replayedAt = new Date(); - event.replayId = replayId; - succeeded++; - } catch (err) { - failed++; - errors.push({ eventId: event.eventId, error: err instanceof Error ? err.message : String(err) }); - this.logger.error(`Replay failed for event ${event.eventId}: ${err}`); - } - } - - const completedAt = new Date(); - const result: ReplayResult = { - replayId, - contractId, - eventsProcessed: events.length, - eventsSucceeded: succeeded, - eventsFailed: failed, - status: failed === 0 ? 'completed' : failed === events.length ? 'failed' : 'partial', - startedAt, - completedAt, - errors: errors.length > 0 ? errors : undefined, - }; - - this.logger.log(`Replay ${replayId} completed: ${succeeded}/${events.length} events (${result.status})`); - return result; - } - - async reconcileState(contractId: string): Promise { - const events = this.eventLog.filter((e) => e.contractId === contractId); - const deployment = this.deployments.get(contractId); - const discrepancies: StateDiscrepancy[] = []; - - if (!deployment) { - discrepancies.push({ field: 'deployment', expected: 'exists', actual: 'missing', severity: 'critical' }); - } - - const invocationEvents = events.filter((e) => e.eventType === 'invoke'); - const recordedCount = this.invocationCounts.get(contractId) ?? 0; - if (invocationEvents.length !== recordedCount) { - discrepancies.push({ field: 'invocationCount', expected: invocationEvents.length, actual: recordedCount, severity: 'warning' }); - } - - const unreplayedCount = events.filter((e) => !e.replayed).length; - if (unreplayedCount > 0) { - discrepancies.push({ field: 'unreplayedEvents', expected: 0, actual: unreplayedCount, severity: 'info' }); - } - - return { - contractId, - isConsistent: discrepancies.filter((d) => d.severity === 'critical').length === 0, - eventLogCount: events.length, - currentStateVersion: this.invocationCounts.get(contractId) ?? 0, - discrepancies, - reconciledAt: new Date(), - }; - } - - getReplayHistory(contractId?: string): ReplayResult[] { - const replays: ReplayResult[] = []; - const replayIds = new Set(); - for (const event of this.eventLog) { - if (event.replayId && !replayIds.has(event.replayId)) { - replayIds.add(event.replayId); - const contractEvents = this.eventLog.filter((e) => e.replayId === event.replayId); - replays.push({ - replayId: event.replayId, - contractId: event.contractId, - eventsProcessed: contractEvents.length, - eventsSucceeded: contractEvents.filter((e) => e.replayed).length, - eventsFailed: contractEvents.filter((e) => !e.replayed).length, - status: 'completed', - startedAt: event.replayedAt ?? event.recordedAt, - completedAt: event.replayedAt ?? event.recordedAt, - }); - } - } - return contractId ? replays.filter((r) => r.contractId === contractId) : replays; - } - - getEventLogStats(): { - totalEvents: number; - unreplayedEvents: number; - eventsByContract: Record; - } { - const eventsByContract: Record = {}; - let unreplayed = 0; - for (const event of this.eventLog) { - eventsByContract[event.contractId] = (eventsByContract[event.contractId] ?? 0) + 1; - if (!event.replayed) unreplayed++; - } - return { totalEvents: this.eventLog.length, unreplayedEvents: unreplayed, eventsByContract }; - } - - // ────────────────────────────────────────────────────────────────── - // #396: IContractAdapter implementation - // ────────────────────────────────────────────────────────────────── - - async recordReward(userId: string, amount: number, reason: string): Promise<{ transactionHash: string; blockNumber: number }> { - const txHash = this.generateTransactionHash(); - this.logger.log(`[Adapter] Recording reward: userId=${userId}, amount=${amount}, reason=${reason}`); - this.appendEventLog('reward_contract', 'record_reward', { userId, amount, reason, txHash }); - return { transactionHash: txHash, blockNumber: Date.now() }; - } - - async recordPayment(fromUserId: string, toUserId: string, amount: number, currency: string, memo?: string): Promise<{ transactionHash: string; timestamp: Date }> { - const txHash = this.generateTransactionHash(); - const timestamp = new Date(); - this.logger.log(`[Adapter] Recording payment: from=${fromUserId} to=${toUserId}, amount=${amount} ${currency}`); - this.appendEventLog('payment_contract', 'record_payment', { fromUserId, toUserId, amount, currency, memo, txHash }); - return { transactionHash: txHash, timestamp }; - } - - async mintCertificate(userId: string, courseId: string, metadata: Record): Promise<{ tokenId: string; transactionHash: string }> { - const tokenId = `cert_${uuidv4()}`; - const txHash = this.generateTransactionHash(); - this.logger.log(`[Adapter] Minting certificate: userId=${userId}, courseId=${courseId}`); - this.appendEventLog('certificate_contract', 'mint_certificate', { userId, courseId, metadata, tokenId, txHash }); - return { tokenId, transactionHash: txHash }; - } - - async mintBadge(userId: string, badgeType: string, metadata: Record): Promise<{ tokenId: string; transactionHash: string }> { - const tokenId = `badge_${uuidv4()}`; - const txHash = this.generateTransactionHash(); - this.logger.log(`[Adapter] Minting badge: userId=${userId}, badgeType=${badgeType}`); - this.appendEventLog('badge_contract', 'mint_badge', { userId, badgeType, metadata, tokenId, txHash }); - return { tokenId, transactionHash: txHash }; - } - - async healthCheck(): Promise<{ isHealthy: boolean; network: string; latency: number }> { - const start = Date.now(); - const network = this.configService.get('CONTRACT_NETWORK') ?? 'testnet'; - const latency = Date.now() - start; - return { isHealthy: this.ingestionEnabled, network, latency }; - } - - // ────────────────────────────────────────────────────────────────── - // Private helpers - // ────────────────────────────────────────────────────────────────── - - private appendEventLog(contractId: string, eventType: string, payload: Record): void { - this.eventSequenceCounter++; - const entry: ContractEventLogEntry = { - eventId: `event_${uuidv4()}`, - contractId, - eventType, - payload, - recordedAt: new Date(), - sequenceNumber: this.eventSequenceCounter, - replayed: false, - }; - this.eventLog.push(entry); - this.pruneEventLog(); - } - - private pruneEventLog(): void { - const retentionDays = this.configService.get('CONTRACT_EVENT_RETENTION_DAYS', 90); - const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000); - const before = this.eventLog.length; - const pruned = this.eventLog.filter((e) => e.recordedAt >= cutoff || !e.replayed); - this.eventLog.length = 0; - this.eventLog.push(...pruned); - if (before !== this.eventLog.length) { - this.logger.log(`Pruned ${before - this.eventLog.length} events older than ${retentionDays} days`); - } - } - - private validateContractId(contractId: string): void { - if (!contractId || !contractId.trim()) { - throw new BadRequestException({ error: 'INVALID_CONTRACT_ID', message: 'contractId is required' }); - } - } - - private validateSourceAccount(sourceAccount: string): void { - if (!sourceAccount || !sourceAccount.trim()) { - throw new BadRequestException({ error: 'INVALID_SOURCE_ACCOUNT', message: 'sourceAccount is required' }); - } - if (!sourceAccount.startsWith('G') || sourceAccount.length !== 56) { - throw new BadRequestException({ - error: 'INVALID_SOURCE_ACCOUNT', - message: 'sourceAccount must be a valid Stellar public key starting with G and 56 characters long', - }); - } - } - - private simulateInvocation(method: string, args: string[]): { success: boolean; value?: unknown; error?: string } { - const successRate = 0.9; - const succeeded = Math.random() < successRate; - if (!succeeded) { - return { success: false, error: `Contract invocation failed: method ${method} reverted` }; - } - switch (method) { - case 'balance': - return { success: true, value: { amount: (Math.random() * 10000).toFixed(2), token: 'XLM' } }; - case 'transfer': - return { success: true, value: { from: args[0] ?? 'unknown', to: args[1] ?? 'unknown', amount: args[2] ?? '0', timestamp: new Date().toISOString() } }; - case 'allowance': - return { success: true, value: { owner: args[0] ?? 'unknown', spender: args[1] ?? 'unknown', amount: '1000' } }; - default: - return { success: true, value: { method, args, result: 'ok' } }; - } - } - - private generateTransactionHash(): string { - return Array.from({ length: 64 }, () => Math.floor(Math.random() * 16).toString(16)).join(''); - } - - private recordInvocation(contractId: string, result: ContractInvocationResult): void { - if (!this.invocationHistory.has(contractId)) { - this.invocationHistory.set(contractId, []); - } - this.invocationHistory.get(contractId)!.push(result); - this.invocationCounts.set(contractId, (this.invocationCounts.get(contractId) ?? 0) + 1); - } -} \ No newline at end of file diff --git a/BackendAcademy/src/contracts/contracts.types.ts b/BackendAcademy/src/contracts/contracts.types.ts deleted file mode 100644 index 5ec03fdbc6..0000000000 --- a/BackendAcademy/src/contracts/contracts.types.ts +++ /dev/null @@ -1,28 +0,0 @@ -export type PayoutRole = 'ADMIN' | 'TREASURY'; -export type PayoutStatus = 'PENDING' | 'COMPLETED' | 'FAILED'; - -export interface PayoutActor { - id: string; - role: PayoutRole; -} - -export interface Payout { - id: string; - recipientId: string; - amount: number; - status: PayoutStatus; - createdBy: string; - createdAt: Date; - releasedBy?: string; - releasedAt?: Date; -} - -export interface PayoutAuditEntry { - action: 'CREATE' | 'RELEASE' | 'FAIL'; - payoutId: string; - actorId: string; - actorRole: PayoutRole; - outcome: 'SUCCESS' | 'DENIED' | 'REJECTED'; - reason?: string; - createdAt: Date; -} \ No newline at end of file diff --git a/BackendAcademy/src/contracts/dto/governance.dto.ts b/BackendAcademy/src/contracts/dto/governance.dto.ts deleted file mode 100644 index a77aabaa31..0000000000 --- a/BackendAcademy/src/contracts/dto/governance.dto.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { IsString, IsNotEmpty } from 'class-validator'; - -export class CreateProposalDto { - @IsString() - @IsNotEmpty() - title: string; - - @IsString() - @IsNotEmpty() - description: string; - - @IsString() - @IsNotEmpty() - proposer: string; -} - -export class CastVoteDto { - @IsString() - @IsNotEmpty() - userId: string; - - @IsString() - @IsNotEmpty() - vote: 'yes' | 'no'; -} diff --git a/BackendAcademy/src/contracts/dto/invoke-contract.dto.ts b/BackendAcademy/src/contracts/dto/invoke-contract.dto.ts deleted file mode 100644 index bc5bb6f763..0000000000 --- a/BackendAcademy/src/contracts/dto/invoke-contract.dto.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { IsArray, IsNumber, IsOptional, IsString, Min } from 'class-validator'; - -export class InvokeContractDto { - @IsString() - contractId: string; - - @IsString() - method: string; - - @IsArray() - @IsString({ each: true }) - args: string[]; - - @IsString() - sourceAccount: string; - - @IsOptional() - @IsNumber() - @Min(0) - fee?: number; -} - -export class DeployContractDto { - @IsString() - contractId: string; - - @IsString() - wasmHash: string; - - @IsString() - deployedBy: string; - - @IsString() - network: string; -} diff --git a/BackendAcademy/src/contracts/index.ts b/BackendAcademy/src/contracts/index.ts deleted file mode 100644 index 6f8bc6b433..0000000000 --- a/BackendAcademy/src/contracts/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { ContractsModule } from './contracts.module'; -export { ContractsService } from './contracts.service'; -export { ContractRegistryService } from './contract-registry.service'; -export { ContractsController } from './contracts.controller'; -export { IContractAdapter } from './interfaces/contracts.interface'; diff --git a/BackendAcademy/src/contracts/interfaces/contracts.interface.ts b/BackendAcademy/src/contracts/interfaces/contracts.interface.ts deleted file mode 100644 index 13e638300e..0000000000 --- a/BackendAcademy/src/contracts/interfaces/contracts.interface.ts +++ /dev/null @@ -1,252 +0,0 @@ -export interface ReputationRecord { - userId: string; - score: number; - level: number; - lastUpdated: Date; -} - -export interface CertificateNft { - id: string; - userId: string; - courseId: string; - issuedAt: Date; - txHash?: string; -} - -export interface BadgeNft { - id: string; - userId: string; - badgeType: string; - issuedAt: Date; - txHash?: string; -} - -export interface EscrowPayout { - id: string; - userId: string; - amount: number; - currency: string; - status: 'pending' | 'completed' | 'failed'; - createdAt: Date; -} - -export interface ContractInvocation { - contractId: string; - method: string; - args: string[]; - sourceAccount: string; - fee?: number; -} - -export interface Vote { - userId: string; - vote: 'yes' | 'no'; - votedAt: Date; -} - -export interface Proposal { - id: string; - title: string; - description: string; - proposer: string; - votes: Vote[]; - createdAt: Date; -} - - -export interface ContractInvocationResult { - invocationId: string; - contractId: string; - method: string; - success: boolean; - result?: unknown; - error?: string; - transactionHash?: string; - executedAt: Date; -} - -export interface ContractDeployment { - contractId: string; - wasmHash: string; - deployedAt: Date; - deployedBy: string; - network: 'testnet' | 'futurenet' | 'mainnet'; -} - -export interface ContractHealth { - contractId: string; - status: 'active' | 'degraded' | 'inactive'; - lastInvokedAt?: Date; - invocationCount: number; - network: string; -} - -export interface ContractInfo { - contractId: string; - wasmHash: string; - network: string; - deployedBy: string; - deployedAt: Date; - methods: string[]; -} - -export interface GovernanceProposal { - id: string; - title: string; - description: string; - proposer: string; - yesVotes: number; - noVotes: number; - status: 'active' | 'passed' | 'rejected'; - createdAt: Date; -} - -// ──────────────────────────────────────────────────────────────────── -// Issue #393: Contract registry types -// ──────────────────────────────────────────────────────────────────── - -/** Schema definition for a contract registered in the registry */ -export interface ContractSchemaDefinition { - /** Semantic version of the contract schema (e.g. "1.2.0") */ - version: string; - /** Type of contract (e.g. "token", "governance", "escrow") */ - contractType: string; - /** Public entry point function names */ - entryPoints: string[]; - /** Method signatures with arg validation */ - methods?: ContractMethodSignature[]; - /** Whether the contract has been tested on mainnet */ - isMainnetCompatible?: boolean; - /** Arbitrary metadata key-value pairs */ - metadata?: Record; -} - -/** A single method signature in a contract schema */ -export interface ContractMethodSignature { - name: string; - args: string[]; - returnType?: string; - description?: string; -} - -/** Validation result for a contract registry entry */ -export interface ContractRegistryValidationResult { - valid: boolean; - errors: string[]; - warnings?: string[]; -} - -/** A contract registry entry */ -export interface ContractRegistryEntry { - id: string; - contractId: string; - wasmHash: string; - network: 'testnet' | 'futurenet' | 'mainnet'; - deployedBy: string; - version?: number; - schema?: ContractSchemaDefinition; - registeredAt: Date; - validatedAt: Date; - validationStatus: 'valid' | 'warning' | 'invalid'; -} - -/** Filter options for listing registry entries */ -export interface ContractRegistryFilter { - network?: string; - validationStatus?: 'valid' | 'warning' | 'invalid'; - deployedBy?: string; -} - -// ──────────────────────────────────────────────────────────────────── -// Issue #394: Event replay and state reconciliation types -// ──────────────────────────────────────────────────────────────────── - -/** A single contract event in the event log for replay */ -export interface ContractEventLogEntry { - eventId: string; - contractId: string; - eventType: string; - payload: Record; - transactionHash?: string; - recordedAt: Date; - sequenceNumber: number; - replayed: boolean; - replayedAt?: Date; - replayId?: string; -} - -/** Result of a replay operation */ -export interface ReplayResult { - replayId: string; - contractId: string; - eventsProcessed: number; - eventsSucceeded: number; - eventsFailed: number; - status: 'completed' | 'partial' | 'failed'; - startedAt: Date; - completedAt: Date; - errors?: Array<{ eventId: string; error: string }>; -} - -/** State reconciliation result comparing event log to current state */ -export interface StateReconciliationResult { - contractId: string; - isConsistent: boolean; - eventLogCount: number; - currentStateVersion: number; - discrepancies: StateDiscrepancy[]; - reconciledAt: Date; -} - -/** A single discrepancy found during state reconciliation */ -export interface StateDiscrepancy { - field: string; - expected: unknown; - actual: unknown; - severity: 'critical' | 'warning' | 'info'; -} - -// ──────────────────────────────────────────────────────────────────── -// Issue #396: Contract adapter boundary types -// ──────────────────────────────────────────────────────────────────── - -/** - * Contract adapter interface that isolates contract integration - * from core domain services. All contract operations should go - * through this adapter rather than reaching directly into - * contract-specific logic. - */ -export interface IContractAdapter { - /** Record a reward on-chain for a user */ - recordReward( - userId: string, - amount: number, - reason: string, - ): Promise<{ transactionHash: string; blockNumber: number }>; - - /** Record a payment transaction on-chain */ - recordPayment( - fromUserId: string, - toUserId: string, - amount: number, - currency: string, - memo?: string, - ): Promise<{ transactionHash: string; timestamp: Date }>; - - /** Mint a certificate NFT for a completed course */ - mintCertificate( - userId: string, - courseId: string, - metadata: Record, - ): Promise<{ tokenId: string; transactionHash: string }>; - - /** Mint a badge NFT for an achievement */ - mintBadge( - userId: string, - badgeType: string, - metadata: Record, - ): Promise<{ tokenId: string; transactionHash: string }>; - - /** Check the health/connectivity of the contract adapter */ - healthCheck(): Promise<{ isHealthy: boolean; network: string; latency: number }>; -} diff --git a/BackendAcademy/src/contracts/repositories/contract-registry.repository.in-memory.ts b/BackendAcademy/src/contracts/repositories/contract-registry.repository.in-memory.ts deleted file mode 100644 index cde5f43056..0000000000 --- a/BackendAcademy/src/contracts/repositories/contract-registry.repository.in-memory.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { - ContractRegistryEntry, - ContractRegistryFilter, -} from '../interfaces/contracts.interface'; -import { IContractRegistryRepository } from './contract-registry.repository.interface'; - -/** - * In-memory implementation of the contract registry repository. - * Stores contract registry entries in process-local Map. - */ -export class InMemoryContractRegistryRepository implements IContractRegistryRepository { - private readonly registry = new Map(); - - register(entry: ContractRegistryEntry): void { - this.registry.set(entry.contractId, entry); - } - - get(contractId: string): ContractRegistryEntry | undefined { - return this.registry.get(contractId); - } - - list(filter?: ContractRegistryFilter): ContractRegistryEntry[] { - let entries = Array.from(this.registry.values()); - - if (filter?.network) { - entries = entries.filter((e) => e.network === filter.network); - } - if (filter?.validationStatus) { - entries = entries.filter((e) => e.validationStatus === filter.validationStatus); - } - if (filter?.deployedBy) { - entries = entries.filter((e) => e.deployedBy === filter.deployedBy); - } - - return entries; - } - - deregister(contractId: string): boolean { - return this.registry.delete(contractId); - } - - has(contractId: string): boolean { - return this.registry.has(contractId); - } - - count(): number { - return this.registry.size; - } - - clearAll(): void { - this.registry.clear(); - } -} diff --git a/BackendAcademy/src/contracts/repositories/contract-registry.repository.interface.ts b/BackendAcademy/src/contracts/repositories/contract-registry.repository.interface.ts deleted file mode 100644 index 5c7c23ae3a..0000000000 --- a/BackendAcademy/src/contracts/repositories/contract-registry.repository.interface.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { - ContractRegistryEntry, - ContractRegistryFilter, -} from '../interfaces/contracts.interface'; - -/** - * Repository interface for contract registry storage. - * Isolates persistence concerns from business logic. - */ -export interface IContractRegistryRepository { - /** - * Register a new contract. - */ - register(entry: ContractRegistryEntry): void; - - /** - * Get a contract registry entry by contract ID. - */ - get(contractId: string): ContractRegistryEntry | undefined; - - /** - * List registry entries, optionally filtered. - */ - list(filter?: ContractRegistryFilter): ContractRegistryEntry[]; - - /** - * Remove a registry entry by contract ID. - */ - deregister(contractId: string): boolean; - - /** - * Check if a contract is already registered. - */ - has(contractId: string): boolean; - - /** - * Get the total number of registered contracts. - */ - count(): number; - - /** - * Clear all registry data (useful for testing). - */ - clearAll(): void; -} diff --git a/BackendAcademy/src/contracts/repositories/contracts.repository.in-memory.ts b/BackendAcademy/src/contracts/repositories/contracts.repository.in-memory.ts deleted file mode 100644 index 19243e6c00..0000000000 --- a/BackendAcademy/src/contracts/repositories/contracts.repository.in-memory.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { - ContractDeployment, - ContractInvocationResult, - ContractEventLogEntry, -} from '../interfaces/contracts.interface'; -import { IContractsRepository } from './contracts.repository.interface'; - -/** - * In-memory implementation of the contracts repository. - * Stores contracts, invocations, and domain data in process-local Maps and arrays. - */ -export class InMemoryContractsRepository implements IContractsRepository { - private readonly deployments = new Map(); - private readonly invocationHistory = new Map(); - private readonly invocationCounts = new Map(); - - // Domain stores - private readonly reputations = new Map(); - private readonly certificates = new Map(); - private readonly badges = new Map(); - private readonly payouts = new Map(); - private readonly proposals = new Map(); - - // Event log - private readonly eventLog: ContractEventLogEntry[] = []; - - // Contract deployments - getDeployment(contractId: string): ContractDeployment | undefined { - return this.deployments.get(contractId); - } - - setDeployment(contractId: string, deployment: ContractDeployment): void { - this.deployments.set(contractId, deployment); - } - - getAllDeployments(): ContractDeployment[] { - return Array.from(this.deployments.values()); - } - - hasDeployment(contractId: string): boolean { - return this.deployments.has(contractId); - } - - // Invocation history - getInvocationHistory(contractId: string): ContractInvocationResult[] { - return this.invocationHistory.get(contractId) ?? []; - } - - recordInvocation(contractId: string, result: ContractInvocationResult): void { - const history = this.invocationHistory.get(contractId) ?? []; - history.push(result); - this.invocationHistory.set(contractId, history); - - const count = (this.invocationCounts.get(contractId) ?? 0) + 1; - this.invocationCounts.set(contractId, count); - } - - getInvocationCount(contractId: string): number { - return this.invocationCounts.get(contractId) ?? 0; - } - - setInvocationCount(contractId: string, count: number): void { - this.invocationCounts.set(contractId, count); - } - - // Domain stores - getReputation(userId: string): { userId: string; score: number; level: number; lastUpdated: Date } | undefined { - return this.reputations.get(userId); - } - - setReputation(userId: string, data: { userId: string; score: number; level: number; lastUpdated: Date }): void { - this.reputations.set(userId, data); - } - - getCertificate(id: string): { id: string; userId: string; courseId: string; issuedAt: Date } | undefined { - return this.certificates.get(id); - } - - setCertificate(id: string, cert: { id: string; userId: string; courseId: string; issuedAt: Date }): void { - this.certificates.set(id, cert); - } - - listCertificates(userId: string): { id: string; userId: string; courseId: string; issuedAt: Date }[] { - return Array.from(this.certificates.values()).filter((c) => c.userId === userId); - } - - getBadge(id: string): { id: string; userId: string; badgeType: string; issuedAt: Date } | undefined { - return this.badges.get(id); - } - - setBadge(id: string, badge: { id: string; userId: string; badgeType: string; issuedAt: Date }): void { - this.badges.set(id, badge); - } - - listBadges(userId: string): { id: string; userId: string; badgeType: string; issuedAt: Date }[] { - return Array.from(this.badges.values()).filter((b) => b.userId === userId); - } - - getPayout(id: string): { id: string; userId: string; amount: number; currency: string; status: 'pending' | 'completed' | 'failed'; createdAt: Date } | undefined { - return this.payouts.get(id); - } - - setPayout(id: string, payout: { id: string; userId: string; amount: number; currency: string; status: 'pending' | 'completed' | 'failed'; createdAt: Date }): void { - this.payouts.set(id, payout); - } - - getProposal(id: string): { - id: string; title: string; description: string; proposer: string; - yesVotes: number; noVotes: number; - status: 'active' | 'passed' | 'rejected'; createdAt: Date; - } | undefined { - return this.proposals.get(id); - } - - setProposal(id: string, proposal: { - id: string; title: string; description: string; proposer: string; - yesVotes: number; noVotes: number; - status: 'active' | 'passed' | 'rejected'; createdAt: Date; - }): void { - this.proposals.set(id, proposal); - } - - listProposals(): { - id: string; title: string; description: string; proposer: string; - yesVotes: number; noVotes: number; - status: 'active' | 'passed' | 'rejected'; createdAt: Date; - }[] { - return Array.from(this.proposals.values()); - } - - // Event log - getEventLog(contractId: string, options?: { onlyUnreplayed?: boolean; limit?: number }): ContractEventLogEntry[] { - let events = this.eventLog.filter((e) => e.contractId === contractId); - if (options?.onlyUnreplayed) { - events = events.filter((e) => !e.replayed); - } - events.sort((a, b) => a.sequenceNumber - b.sequenceNumber); - if (options?.limit && options.limit > 0) { - events = events.slice(0, options.limit); - } - return events; - } - - appendEventLog(entry: ContractEventLogEntry): void { - this.eventLog.push(entry); - } - - getEventLogStats(): { totalEvents: number; unreplayedEvents: number; eventsByContract: Record } { - const eventsByContract: Record = {}; - let unreplayed = 0; - for (const event of this.eventLog) { - eventsByContract[event.contractId] = (eventsByContract[event.contractId] ?? 0) + 1; - if (!event.replayed) unreplayed++; - } - return { totalEvents: this.eventLog.length, unreplayedEvents: unreplayed, eventsByContract }; - } - - clearAll(): void { - this.deployments.clear(); - this.invocationHistory.clear(); - this.invocationCounts.clear(); - this.reputations.clear(); - this.certificates.clear(); - this.badges.clear(); - this.payouts.clear(); - this.proposals.clear(); - this.eventLog.length = 0; - } -} diff --git a/BackendAcademy/src/contracts/repositories/contracts.repository.interface.ts b/BackendAcademy/src/contracts/repositories/contracts.repository.interface.ts deleted file mode 100644 index a06812e661..0000000000 --- a/BackendAcademy/src/contracts/repositories/contracts.repository.interface.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { - ContractDeployment, - ContractInvocationResult, - ContractEventLogEntry, -} from '../interfaces/contracts.interface'; - -/** - * Repository interface for contracts storage. - * Isolates persistence concerns from business logic. - */ -export interface IContractsRepository { - // Contract deployments - getDeployment(contractId: string): ContractDeployment | undefined; - setDeployment(contractId: string, deployment: ContractDeployment): void; - getAllDeployments(): ContractDeployment[]; - hasDeployment(contractId: string): boolean; - - // Invocation history - getInvocationHistory(contractId: string): ContractInvocationResult[]; - recordInvocation(contractId: string, result: ContractInvocationResult): void; - getInvocationCount(contractId: string): number; - setInvocationCount(contractId: string, count: number): void; - - // Domain stores - getReputation(userId: string): { userId: string; score: number; level: number; lastUpdated: Date } | undefined; - setReputation(userId: string, data: { userId: string; score: number; level: number; lastUpdated: Date }): void; - - getCertificate(id: string): { id: string; userId: string; courseId: string; issuedAt: Date } | undefined; - setCertificate(id: string, cert: { id: string; userId: string; courseId: string; issuedAt: Date }): void; - listCertificates(userId: string): { id: string; userId: string; courseId: string; issuedAt: Date }[]; - - getBadge(id: string): { id: string; userId: string; badgeType: string; issuedAt: Date } | undefined; - setBadge(id: string, badge: { id: string; userId: string; badgeType: string; issuedAt: Date }): void; - listBadges(userId: string): { id: string; userId: string; badgeType: string; issuedAt: Date }[]; - - getPayout(id: string): { id: string; userId: string; amount: number; currency: string; status: 'pending' | 'completed' | 'failed'; createdAt: Date } | undefined; - setPayout(id: string, payout: { id: string; userId: string; amount: number; currency: string; status: 'pending' | 'completed' | 'failed'; createdAt: Date }): void; - - getProposal(id: string): { - id: string; title: string; description: string; proposer: string; - yesVotes: number; noVotes: number; - status: 'active' | 'passed' | 'rejected'; createdAt: Date; - } | undefined; - setProposal(id: string, proposal: { - id: string; title: string; description: string; proposer: string; - yesVotes: number; noVotes: number; - status: 'active' | 'passed' | 'rejected'; createdAt: Date; - }): void; - listProposals(): { - id: string; title: string; description: string; proposer: string; - yesVotes: number; noVotes: number; - status: 'active' | 'passed' | 'rejected'; createdAt: Date; - }[]; - - // Event log - getEventLog(contractId: string, options?: { onlyUnreplayed?: boolean; limit?: number }): ContractEventLogEntry[]; - appendEventLog(entry: ContractEventLogEntry): void; - getEventLogStats(): { totalEvents: number; unreplayedEvents: number; eventsByContract: Record }; - - // Clear all data (useful for testing) - clearAll(): void; -} diff --git a/BackendAcademy/src/courses/certificate.service.spec.ts b/BackendAcademy/src/courses/certificate.service.spec.ts deleted file mode 100644 index e74ba4d30c..0000000000 --- a/BackendAcademy/src/courses/certificate.service.spec.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { CertificateService } from './certificate.service'; - -function createService() { - return new CertificateService({ - get: (key: string, fallback: string) => fallback, - } as any); -} - -describe('CertificateService', () => { - let service: CertificateService; - - beforeEach(() => { - service = createService(); - }); - - it('generates a certificate with expected fields', async () => { - const cert = await service.generateCertificate({ - userId: 'user-1', - courseId: 'course-1', - courseTitle: 'Rust 101', - xpAwarded: 50, - }); - - expect(cert.id).toMatch(/^cert_/); - expect(cert.userId).toBe('user-1'); - expect(cert.courseId).toBe('course-1'); - expect(cert.courseTitle).toBe('Rust 101'); - expect(cert.xpAwarded).toBe(50); - expect(cert.status).toBe('active'); - expect(cert.verificationCode).toBeDefined(); - expect(cert.shareableUrl).toContain('/verify/'); - expect(cert.issuedAt).toBeInstanceOf(Date); - }); - - it('is idempotent: returning the same certificate for repeated calls', async () => { - const first = await service.generateCertificate({ - userId: 'user-1', - courseId: 'course-1', - courseTitle: 'Rust 101', - xpAwarded: 50, - }); - - const second = await service.generateCertificate({ - userId: 'user-1', - courseId: 'course-1', - courseTitle: 'Rust 101', - xpAwarded: 50, - }); - - expect(second.id).toBe(first.id); - expect(second.verificationCode).toBe(first.verificationCode); - expect(second.issuedAt).toBe(first.issuedAt); - }); - - it('creates separate certificates for different users on the same course', async () => { - const a = await service.generateCertificate({ - userId: 'user-1', - courseId: 'course-1', - courseTitle: 'Rust 101', - xpAwarded: 50, - }); - - const b = await service.generateCertificate({ - userId: 'user-2', - courseId: 'course-1', - courseTitle: 'Rust 101', - xpAwarded: 50, - }); - - expect(b.id).not.toBe(a.id); - }); - - it('creates separate certificates for the same user on different courses', async () => { - const a = await service.generateCertificate({ - userId: 'user-1', - courseId: 'course-1', - courseTitle: 'Rust 101', - xpAwarded: 50, - }); - - const b = await service.generateCertificate({ - userId: 'user-1', - courseId: 'course-2', - courseTitle: 'Rust 201', - xpAwarded: 100, - }); - - expect(b.id).not.toBe(a.id); - }); - - it('creates a new certificate after the existing one is revoked', async () => { - const first = await service.generateCertificate({ - userId: 'user-1', - courseId: 'course-1', - courseTitle: 'Rust 101', - xpAwarded: 50, - }); - - service.revokeCertificate(first.id); - - const second = await service.generateCertificate({ - userId: 'user-1', - courseId: 'course-1', - courseTitle: 'Rust 101', - xpAwarded: 50, - }); - - // New certificate is created since the old one was revoked - expect(second.id).not.toBe(first.id); - expect(second.status).toBe('active'); - }); - - it('verifies a valid certificate', async () => { - const cert = await service.generateCertificate({ - userId: 'user-1', - courseId: 'course-1', - courseTitle: 'Rust 101', - xpAwarded: 50, - }); - - const result = service.verifyCertificate(cert.verificationCode); - expect(result.valid).toBe(true); - expect(result.certificate?.id).toBe(cert.id); - }); - - it('rejects verification for a revoked certificate', async () => { - const cert = await service.generateCertificate({ - userId: 'user-1', - courseId: 'course-1', - courseTitle: 'Rust 101', - xpAwarded: 50, - }); - - service.revokeCertificate(cert.id); - - const result = service.verifyCertificate(cert.verificationCode); - expect(result.valid).toBe(false); - expect(result.reason).toBe('Certificate has been revoked'); - }); - - it('returns issuance summary with correct counts', async () => { - await service.generateCertificate({ - userId: 'user-1', - courseId: 'course-1', - courseTitle: 'Rust 101', - xpAwarded: 50, - }); - await service.generateCertificate({ - userId: 'user-2', - courseId: 'course-1', - courseTitle: 'Rust 101', - xpAwarded: 50, - }); - await service.generateCertificate({ - userId: 'user-1', - courseId: 'course-2', - courseTitle: 'Rust 201', - xpAwarded: 100, - }); - - const summary = service.getIssuanceSummary(); - expect(summary.totalIssued).toBe(3); - expect(summary.totalActive).toBe(3); - expect(summary.issuedByCourse).toHaveLength(2); - }); -}); diff --git a/BackendAcademy/src/courses/certificate.service.ts b/BackendAcademy/src/courses/certificate.service.ts deleted file mode 100644 index 01272ee7a2..0000000000 --- a/BackendAcademy/src/courses/certificate.service.ts +++ /dev/null @@ -1,289 +0,0 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { v4 as uuidv4 } from 'uuid'; - -/** - * Represents a generated course completion certificate. - * - * Each certificate carries a unique `verificationCode` that can be - * used by external parties to verify authenticity without needing - * on-chain access. The `shareableUrl` points to a public verification - * endpoint (e.g. `/certificates/verify/:code`). - */ -export interface CertificateRecord { - id: string; - userId: string; - courseId: string; - courseTitle: string; - userName?: string; - issuedAt: Date; - verificationCode: string; - shareableUrl: string; - xpAwarded: number; - completionDate: string; - status: 'active' | 'revoked'; -} - -/** - * Summary of certificate issuance for reporting purposes. - */ -export interface CertificateIssuanceSummary { - totalIssued: number; - totalActive: number; - totalRevoked: number; - issuedByCourse: Array<{ - courseId: string; - courseTitle: string; - count: number; - }>; - recentIssuances: CertificateRecord[]; -} - -/** - * Handles the full lifecycle of course completion certificates: - * - * 1. **Generation** — Creates a verifiable certificate record with a - * unique verification code and shareable URL when a course is - * completed. - * 2. **Verification** — Allows external parties to look up and verify - * a certificate by its verification code. - * 3. **Reporting** — Aggregates certificate issuance data for admin - * dashboards and audit trails. - * - * Certificates are stored in an in-memory store for now; production - * deployments should persist them via TypeORM or a dedicated - * certificate microservice. - */ -@Injectable() -export class CertificateService { - private readonly logger = new Logger(CertificateService.name); - private readonly certificates: Map = new Map(); - private readonly byUser: Map> = new Map(); - private readonly byCourse: Map> = new Map(); - private readonly byVerificationCode: Map = new Map(); - - constructor(private readonly configService: ConfigService) {} - - /** - * #360: Generate a new certificate for a completed course, or return - * the existing active certificate if one already exists for this - * user+course pair. This makes the operation idempotent — repeated - * calls with the same arguments always return the same certificate. - * - * @returns The existing or newly created certificate record. - */ - async generateCertificate(params: { - userId: string; - courseId: string; - courseTitle: string; - userName?: string; - xpAwarded: number; - }): Promise { - // Idempotent: return existing active certificate if present - const existing = this.findActiveByUserAndCourse( - params.userId, - params.courseId, - ); - if (existing) { - this.logger.log( - `Certificate already exists: ${existing.id} for user=${params.userId}, course=${params.courseId} — returning existing`, - ); - return existing; - } - - const id = `cert_${uuidv4()}`; - const verificationCode = this.generateVerificationCode(); - const baseUrl = this.configService.get( - 'CERTIFICATE_BASE_URL', - 'https://rustacademy.xyz/certificates', - ); - - const certificate: CertificateRecord = { - id, - userId: params.userId, - courseId: params.courseId, - courseTitle: params.courseTitle, - userName: params.userName, - issuedAt: new Date(), - verificationCode, - shareableUrl: `${baseUrl}/verify/${verificationCode}`, - xpAwarded: params.xpAwarded, - completionDate: new Date().toISOString(), - status: 'active', - }; - - this.certificates.set(id, certificate); - - // Maintain secondary indexes for fast lookup - this.indexByUser(params.userId, id); - this.indexByCourse(params.courseId, id); - this.byVerificationCode.set(verificationCode, id); - - this.logger.log( - `Certificate generated: ${id} for user=${params.userId}, course=${params.courseId}`, - ); - - return certificate; - } - - /** - * #360: Find the first active certificate for a user+course pair. - * Returns undefined when no active certificate exists. - */ - private findActiveByUserAndCourse( - userId: string, - courseId: string, - ): CertificateRecord | undefined { - const ids = this.byUser.get(userId); - if (!ids) return undefined; - for (const id of ids) { - const cert = this.certificates.get(id); - if (cert && cert.courseId === courseId && cert.status === 'active') { - return cert; - } - } - return undefined; - } - - /** - * Retrieve a certificate by its internal ID. - */ - getCertificate(id: string): CertificateRecord | null { - return this.certificates.get(id) ?? null; - } - - /** - * Retrieve a certificate by its public verification code. - * This is the primary lookup method for external verification. - */ - getCertificateByVerificationCode( - verificationCode: string, - ): CertificateRecord | null { - const id = this.byVerificationCode.get(verificationCode); - if (!id) return null; - return this.certificates.get(id) ?? null; - } - - /** - * List all certificates issued to a specific user. - */ - listCertificatesByUser(userId: string): CertificateRecord[] { - const ids = this.byUser.get(userId); - if (!ids) return []; - return Array.from(ids) - .map((id) => this.certificates.get(id)) - .filter((c): c is CertificateRecord => c !== undefined); - } - - /** - * List all certificates for a specific course. - */ - listCertificatesByCourse(courseId: string): CertificateRecord[] { - const ids = this.byCourse.get(courseId); - if (!ids) return []; - return Array.from(ids) - .map((id) => this.certificates.get(id)) - .filter((c): c is CertificateRecord => c !== undefined); - } - - /** - * Revoke a certificate (e.g. for academic integrity violations). - */ - revokeCertificate(id: string): CertificateRecord | null { - const cert = this.certificates.get(id); - if (!cert) return null; - cert.status = 'revoked'; - return cert; - } - - /** - * Verify a certificate by its verification code and return a - * human-readable verification result. - */ - verifyCertificate( - verificationCode: string, - ): { - valid: boolean; - certificate?: CertificateRecord; - reason?: string; - } { - const cert = this.getCertificateByVerificationCode(verificationCode); - if (!cert) { - return { valid: false, reason: 'Certificate not found' }; - } - if (cert.status === 'revoked') { - return { valid: false, certificate: cert, reason: 'Certificate has been revoked' }; - } - return { valid: true, certificate: cert }; - } - - /** - * Aggregate certificate issuance data for reporting. - */ - getIssuanceSummary(): CertificateIssuanceSummary { - const all = Array.from(this.certificates.values()); - const byCourseMap = new Map(); - - for (const cert of all) { - const existing = byCourseMap.get(cert.courseId); - if (existing) { - existing.count++; - } else { - byCourseMap.set(cert.courseId, { - courseId: cert.courseId, - courseTitle: cert.courseTitle, - count: 1, - }); - } - } - - const recentIssuances = [...all] - .sort((a, b) => b.issuedAt.getTime() - a.issuedAt.getTime()) - .slice(0, 20); - - return { - totalIssued: all.length, - totalActive: all.filter((c) => c.status === 'active').length, - totalRevoked: all.filter((c) => c.status === 'revoked').length, - issuedByCourse: Array.from(byCourseMap.values()), - recentIssuances, - }; - } - - // ── Private helpers ───────────────────────────────────── - - /** - * Generate a URL-safe verification code. Uses a shorter format than - * UUID for easier sharing (12 characters, alphanumeric). - */ - private generateVerificationCode(): string { - const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; - const segments: string[] = []; - for (let s = 0; s < 3; s++) { - let segment = ''; - for (let i = 0; i < 4; i++) { - segment += chars[Math.floor(Math.random() * chars.length)]; - } - segments.push(segment); - } - return segments.join('-'); - } - - private indexByUser(userId: string, certId: string): void { - let set = this.byUser.get(userId); - if (!set) { - set = new Set(); - this.byUser.set(userId, set); - } - set.add(certId); - } - - private indexByCourse(courseId: string, certId: string): void { - let set = this.byCourse.get(courseId); - if (!set) { - set = new Set(); - this.byCourse.set(courseId, set); - } - set.add(certId); - } -} diff --git a/BackendAcademy/src/courses/completion-evidence.util.ts b/BackendAcademy/src/courses/completion-evidence.util.ts deleted file mode 100644 index 7d53c4f44a..0000000000 --- a/BackendAcademy/src/courses/completion-evidence.util.ts +++ /dev/null @@ -1,28 +0,0 @@ -export interface CompletionEvidence { - userId: string; - courseId: string; - verifiedLessonIds: string[]; - verifiedSubmissionIds: string[]; -} - -export interface CourseRequirements { - requiredLessonIds: string[]; - requiredSubmissionIds: string[]; -} - -/** - * Confirms completion is backed by server-verified lessons/submissions - * against the course's required set, not client-supplied claims. - */ -export function hasVerifiedCompletion( - evidence: CompletionEvidence, - requirements: CourseRequirements, -): boolean { - const lessonsDone = requirements.requiredLessonIds.every((id) => - evidence.verifiedLessonIds.includes(id), - ); - const submissionsDone = requirements.requiredSubmissionIds.every((id) => - evidence.verifiedSubmissionIds.includes(id), - ); - return lessonsDone && submissionsDone; -} diff --git a/BackendAcademy/src/courses/course-rating.entity.ts b/BackendAcademy/src/courses/course-rating.entity.ts deleted file mode 100644 index 86e6664f54..0000000000 --- a/BackendAcademy/src/courses/course-rating.entity.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { - Column, - CreateDateColumn, - Entity, - Index, - PrimaryGeneratedColumn, - UpdateDateColumn, -} from 'typeorm'; - -/** - * A learner's rating and optional review for a course. - * - * Each user can rate a course at most once; submitting a new rating - * will update the existing one (upsert semantics). - */ -@Entity({ name: 'course_ratings' }) -@Index(['courseId', 'userId'], { unique: true }) -export class CourseRatingEntity { - @PrimaryGeneratedColumn('increment') - id: number; - - @Index('idx_course_ratings_course_id') - @Column({ name: 'course_id', type: 'uuid' }) - courseId: string; - - @Index('idx_course_ratings_user_id') - @Column({ name: 'user_id', type: 'uuid' }) - userId: string; - - /** - * Rating value from 1 to 5 (inclusive). - * Stored as SMALLINT with a CHECK constraint in the migration. - */ - @Column({ type: 'smallint' }) - rating: number; - - /** - * Optional text review from the learner. - * Max 2000 characters. - */ - @Column({ type: 'text', nullable: true }) - review: string | null; - - @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) - createdAt: Date; - - @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) - updatedAt: Date; - - constructor(partial: Partial = {}) { - Object.assign(this, partial); - this.createdAt = this.createdAt || new Date(); - this.updatedAt = this.updatedAt || new Date(); - this.review = this.review || null; - } -} diff --git a/BackendAcademy/src/courses/course-rating.service.spec.ts b/BackendAcademy/src/courses/course-rating.service.spec.ts deleted file mode 100644 index 7b7783e596..0000000000 --- a/BackendAcademy/src/courses/course-rating.service.spec.ts +++ /dev/null @@ -1,468 +0,0 @@ -import { NotFoundException } from '@nestjs/common'; -import { CourseRatingService } from './course-rating.service'; -import { CourseRatingEntity } from './course-rating.entity'; -import { CourseEntity } from './course.entity'; -import { CourseLevel } from './interfaces/course-level.enum'; -import { CreateRatingDto } from './dto/create-rating.dto'; - -/** - * Minimal in-memory mock that imitates the subset of the Repository surface - * that CourseRatingService relies on. - */ -class InMemoryRepository { - protected readonly rows: Map = new Map(); - private nextId = 1; - - constructor( - protected readonly EntityCtor?: new (partial?: Partial) => T, - ) {} - - create(partial: Partial = {}): T { - if (this.EntityCtor) { - return new this.EntityCtor(partial); - } - return { ...(partial as T) }; - } - - async save(entity: T): Promise { - if (!entity.id) { - (entity as T & { id: number }).id = this.nextId++; - } - const now = new Date(); - if ('createdAt' in entity && !(entity as { createdAt?: Date }).createdAt) { - (entity as { createdAt: Date }).createdAt = now; - } - if ('updatedAt' in entity) { - (entity as { updatedAt: Date }).updatedAt = now; - } - this.rows.set((entity as T & { id: string | number }).id, entity); - return entity; - } - - async find(options: { - where?: Partial; - order?: { createdAt?: 'DESC' | 'ASC' }; - skip?: number; - take?: number; - } = {}): Promise { - const matches = Object.values(this.matchRows(options.where ?? {})); - if (options.order?.createdAt) { - matches.sort((a, b) => { - const aTime = (a as unknown as { createdAt: Date }).createdAt?.getTime() ?? 0; - const bTime = (b as unknown as { createdAt: Date }).createdAt?.getTime() ?? 0; - return options.order!.createdAt === 'DESC' ? bTime - aTime : aTime - bTime; - }); - } - const skip = options.skip ?? 0; - const take = options.take ?? matches.length; - return matches.slice(skip, skip + take); - } - - async findOne(options: { where: Partial }): Promise { - const matches = Object.values(this.matchRows(options.where)); - return matches[0] ?? null; - } - - async findAndCount(options: { - where?: Partial; - order?: { createdAt?: 'DESC' | 'ASC' }; - skip?: number; - take?: number; - } = {}): Promise<[T[], number]> { - const all = Object.values(this.matchRows(options.where ?? {})); - const total = all.length; - - if (options.order?.createdAt) { - all.sort((a, b) => { - const aTime = (a as unknown as { createdAt: Date }).createdAt?.getTime() ?? 0; - const bTime = (b as unknown as { createdAt: Date }).createdAt?.getTime() ?? 0; - return options.order!.createdAt === 'DESC' ? bTime - aTime : aTime - bTime; - }); - } - - const skip = options.skip ?? 0; - const take = options.take ?? total; - const data = all.slice(skip, skip + take); - - return [data, total]; - } - - async remove(entity: T): Promise { - const id = (entity as T & { id: string | number }).id; - this.rows.delete(id); - return entity; - } - - private matchRows(where: Partial): Record { - const matches: Record = {}; - for (const [id, row] of this.rows.entries()) { - const ok = Object.entries(where as Record).every( - ([key, expected]) => { - const actual = (row as Record)[key]; - if (Array.isArray(expected)) { - return Array.isArray(actual) && - expected.length === actual.length && - expected.every((v, i) => v === (actual as unknown[])[i]); - } - return actual === expected; - }, - ); - if (ok) matches[id] = row; - } - return matches; - } -} - -class InMemoryRatingRepo extends InMemoryRepository { - constructor() { - super(CourseRatingEntity); - } -} - -class InMemoryCourseRepo extends InMemoryRepository { - constructor() { - super(CourseEntity); - } - - async findOne(options: { where: { id: string } }): Promise { - const id = options.where.id; - return this.rows.get(id) as CourseEntity | undefined ?? null; - } -} - -describe('CourseRatingService', () => { - let service: CourseRatingService; - let ratingRepo: InMemoryRatingRepo; - let courseRepo: InMemoryCourseRepo; - let testCourseId: string; - let learnerId: string; - let userId2: string; - - beforeEach(async () => { - ratingRepo = new InMemoryRatingRepo(); - courseRepo = new InMemoryCourseRepo(); - service = new CourseRatingService( - ratingRepo as unknown as import('typeorm').Repository, - courseRepo as unknown as import('typeorm').Repository, - ); - - // Create a test course - testCourseId = crypto.randomUUID(); - learnerId = crypto.randomUUID(); - userId2 = crypto.randomUUID(); - - const course = new CourseEntity({ - id: testCourseId, - title: 'Test Course', - description: 'A test course', - level: CourseLevel.BEGINNER, - order: 1, - learningPathId: 'path-1', - duration: 30, - }); - await courseRepo.save(course); - }); - - // --------------------------------------------------------------------------- - // Submit Rating Tests - // --------------------------------------------------------------------------- - - it('should create a new rating when user has not rated the course', async () => { - const dto: CreateRatingDto = { rating: 5, review: 'Great course!' }; - const result = await service.submitRating(testCourseId, learnerId, dto); - - expect(result).toBeDefined(); - expect(result.courseId).toBe(testCourseId); - expect(result.userId).toBe(learnerId); - expect(result.rating).toBe(5); - expect(result.review).toBe('Great course!'); - expect(result.createdAt).toBeDefined(); - expect(result.updatedAt).toBeDefined(); - }); - - it('should update existing rating when user has already rated the course', async () => { - const dto1: CreateRatingDto = { rating: 3, review: 'Good' }; - const rating1 = await service.submitRating(testCourseId, learnerId, dto1); - const initialId = rating1.id; - const createdAtTime = rating1.createdAt.getTime(); - - // Wait a tiny bit to ensure updatedAt changes - await new Promise((resolve) => setTimeout(resolve, 10)); - - const dto2: CreateRatingDto = { rating: 5, review: 'Excellent!' }; - const rating2 = await service.submitRating(testCourseId, learnerId, dto2); - - // Same ID (upsert, not insert) - expect(rating2.id).toBe(initialId); - expect(rating2.rating).toBe(5); - expect(rating2.review).toBe('Excellent!'); - expect(rating2.createdAt.getTime()).toBe(createdAtTime); - expect(rating2.updatedAt.getTime()).toBeGreaterThan(createdAtTime); - - // Verify only one rating exists in DB - const allRatings = await ratingRepo.find({ where: { courseId: testCourseId } }); - expect(allRatings).toHaveLength(1); - }); - - it('should accept rating without review', async () => { - const dto: CreateRatingDto = { rating: 4 }; - const result = await service.submitRating(testCourseId, learnerId, dto); - - expect(result.rating).toBe(4); - expect(result.review).toBeNull(); - }); - - it('should reject rating for non-existent course', async () => { - const fakeCourseId = crypto.randomUUID(); - const dto: CreateRatingDto = { rating: 5 }; - - await expect( - service.submitRating(fakeCourseId, learnerId, dto), - ).rejects.toThrow(NotFoundException); - }); - - // --------------------------------------------------------------------------- - // List Ratings Tests - // --------------------------------------------------------------------------- - - it('should list ratings for a course with pagination', async () => { - // Insert 25 ratings - for (let i = 0; i < 25; i++) { - const userId = crypto.randomUUID(); - const dto: CreateRatingDto = { rating: (i % 5) + 1 }; - await service.submitRating(testCourseId, userId, dto); - } - - // Get first page (default: 20 per page) - const page1 = await service.listRatings(testCourseId, 1, 20); - expect(page1.data).toHaveLength(20); - expect(page1.total).toBe(25); - expect(page1.page).toBe(1); - expect(page1.perPage).toBe(20); - expect(page1.pages).toBe(2); - - // Get second page - const page2 = await service.listRatings(testCourseId, 2, 20); - expect(page2.data).toHaveLength(5); - expect(page2.page).toBe(2); - - // Verify no overlap - const page1Ids = page1.data.map((r) => r.id); - const page2Ids = page2.data.map((r) => r.id); - expect(new Set([...page1Ids, ...page2Ids]).size).toBe(25); - }); - - it('should default pagination parameters correctly', async () => { - const dto: CreateRatingDto = { rating: 5 }; - await service.submitRating(testCourseId, learnerId, dto); - - const result = await service.listRatings(testCourseId); - expect(result.page).toBe(1); - expect(result.perPage).toBe(20); - expect(result.pages).toBe(1); - expect(result.data).toHaveLength(1); - }); - - it('should clamp invalid pagination parameters', async () => { - const dto: CreateRatingDto = { rating: 5 }; - for (let i = 0; i < 5; i++) { - await service.submitRating(testCourseId, crypto.randomUUID(), dto); - } - - // Negative page → 1 - const badPage = await service.listRatings(testCourseId, -5, 10); - expect(badPage.page).toBe(1); - - // perPage > 100 → 20 - const badPerPage = await service.listRatings(testCourseId, 1, 200); - expect(badPerPage.perPage).toBe(20); - }); - - it('should throw NotFoundException for non-existent course', async () => { - const fakeCourseId = crypto.randomUUID(); - await expect(service.listRatings(fakeCourseId)).rejects.toThrow( - NotFoundException, - ); - }); - - it('should return empty list for course with no ratings', async () => { - const result = await service.listRatings(testCourseId); - expect(result.data).toHaveLength(0); - expect(result.total).toBe(0); - expect(result.pages).toBe(0); - }); - - // --------------------------------------------------------------------------- - // Rating Stats Tests - // --------------------------------------------------------------------------- - - it('should calculate correct average rating', async () => { - // Insert ratings: 5, 4, 3 - await service.submitRating(testCourseId, crypto.randomUUID(), { - rating: 5, - }); - await service.submitRating(testCourseId, crypto.randomUUID(), { - rating: 4, - }); - await service.submitRating(testCourseId, crypto.randomUUID(), { - rating: 3, - }); - - const stats = await service.getRatingStats(testCourseId); - expect(stats.averageRating).toBe(4); // (5+4+3)/3 = 4 - expect(stats.totalRatings).toBe(3); - expect(stats.courseId).toBe(testCourseId); - }); - - it('should return correct rating distribution', async () => { - // Insert: 1×1-star, 2×2-star, 3×3-star, 4×4-star, 5×5-star - await service.submitRating(testCourseId, crypto.randomUUID(), { rating: 1 }); - for (let i = 0; i < 2; i++) - await service.submitRating(testCourseId, crypto.randomUUID(), { - rating: 2, - }); - for (let i = 0; i < 3; i++) - await service.submitRating(testCourseId, crypto.randomUUID(), { - rating: 3, - }); - for (let i = 0; i < 4; i++) - await service.submitRating(testCourseId, crypto.randomUUID(), { - rating: 4, - }); - for (let i = 0; i < 5; i++) - await service.submitRating(testCourseId, crypto.randomUUID(), { - rating: 5, - }); - - const stats = await service.getRatingStats(testCourseId); - expect(stats.ratingDistribution.oneStar).toBe(1); - expect(stats.ratingDistribution.twoStar).toBe(2); - expect(stats.ratingDistribution.threeStar).toBe(3); - expect(stats.ratingDistribution.fourStar).toBe(4); - expect(stats.ratingDistribution.fiveStar).toBe(5); - }); - - it('should round average rating to 2 decimal places', async () => { - // Insert ratings: 5, 5, 4 → average = 4.666... → 4.67 - await service.submitRating(testCourseId, crypto.randomUUID(), { rating: 5 }); - await service.submitRating(testCourseId, crypto.randomUUID(), { rating: 5 }); - await service.submitRating(testCourseId, crypto.randomUUID(), { rating: 4 }); - - const stats = await service.getRatingStats(testCourseId); - expect(stats.averageRating).toBe(4.67); - }); - - it('should return 0 average for course with no ratings', async () => { - const stats = await service.getRatingStats(testCourseId); - expect(stats.averageRating).toBe(0); - expect(stats.totalRatings).toBe(0); - expect(stats.ratingDistribution.oneStar).toBe(0); - expect(stats.ratingDistribution.fiveStar).toBe(0); - }); - - it('should throw NotFoundException for non-existent course', async () => { - const fakeCourseId = crypto.randomUUID(); - await expect(service.getRatingStats(fakeCourseId)).rejects.toThrow( - NotFoundException, - ); - }); - - // --------------------------------------------------------------------------- - // Delete Rating Tests - // --------------------------------------------------------------------------- - - it('should delete a user rating', async () => { - const dto: CreateRatingDto = { rating: 5, review: 'Great!' }; - await service.submitRating(testCourseId, learnerId, dto); - - // Verify rating exists - let rating = await service.getUserRating(testCourseId, learnerId); - expect(rating).not.toBeNull(); - - // Delete it - const deleted = await service.deleteRating(testCourseId, learnerId); - expect(deleted).toBe(true); - - // Verify it's gone - rating = await service.getUserRating(testCourseId, learnerId); - expect(rating).toBeNull(); - }); - - it('should throw NotFoundException when deleting non-existent rating', async () => { - // No rating submitted yet - await expect(service.deleteRating(testCourseId, learnerId)).rejects.toThrow( - NotFoundException, - ); - }); - - it('should only delete own rating (upsert test)', async () => { - // User 1 submits a rating - await service.submitRating(testCourseId, learnerId, { rating: 5 }); - - // User 2 tries to delete User 1's rating → should fail - await expect(service.deleteRating(testCourseId, userId2)).rejects.toThrow( - NotFoundException, - ); - - // User 1's rating should still exist - const rating = await service.getUserRating(testCourseId, learnerId); - expect(rating).not.toBeNull(); - }); - - // --------------------------------------------------------------------------- - // Helper Methods Tests - // --------------------------------------------------------------------------- - - it('should check if user has rated course', async () => { - const hasRated1 = await service.hasUserRated(testCourseId, learnerId); - expect(hasRated1).toBe(false); - - await service.submitRating(testCourseId, learnerId, { rating: 4 }); - - const hasRated2 = await service.hasUserRated(testCourseId, learnerId); - expect(hasRated2).toBe(true); - }); - - it('should retrieve specific user rating', async () => { - const ratingBefore = await service.getUserRating(testCourseId, learnerId); - expect(ratingBefore).toBeNull(); - - const submitted = await service.submitRating(testCourseId, learnerId, { - rating: 3, - review: 'Okay', - }); - - const retrieved = await service.getUserRating(testCourseId, learnerId); - expect(retrieved).not.toBeNull(); - expect(retrieved!.id).toBe(submitted.id); - expect(retrieved!.rating).toBe(3); - expect(retrieved!.review).toBe('Okay'); - }); - - // --------------------------------------------------------------------------- - // Validation Tests (via DTOs) - // --------------------------------------------------------------------------- - - it('should support multiple users rating the same course independently', async () => { - const userIds = Array.from({ length: 5 }, () => crypto.randomUUID()); - for (let i = 0; i < 5; i++) { - await service.submitRating(testCourseId, userIds[i], { rating: i + 1 }); - } - - const stats = await service.getRatingStats(testCourseId); - expect(stats.totalRatings).toBe(5); - // Average = (1+2+3+4+5)/5 = 3 - expect(stats.averageRating).toBe(3); - }); - - it('should preserve review text exactly as submitted', async () => { - const reviewText = 'This course is amazing!\n\nI learned so much.'; - await service.submitRating(testCourseId, learnerId, { - rating: 5, - review: reviewText, - }); - - const rating = await service.getUserRating(testCourseId, learnerId); - expect(rating!.review).toBe(reviewText); - }); -}); \ No newline at end of file diff --git a/BackendAcademy/src/courses/course-rating.service.ts b/BackendAcademy/src/courses/course-rating.service.ts deleted file mode 100644 index a99f2215c6..0000000000 --- a/BackendAcademy/src/courses/course-rating.service.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { CourseRatingEntity } from './course-rating.entity'; -import { CourseEntity } from './course.entity'; -import { CreateRatingDto } from './dto/create-rating.dto'; -import { CourseRatingStatsDto, RatingDistributionDto } from './dto/rating-stats.dto'; - -/** - * Business logic for course ratings and reviews. - * - * Persistence is delegated to injected TypeORM repositories. - * Each user can rate a course at most once; subsequent ratings update the existing one (upsert semantics). - */ -@Injectable() -export class CourseRatingService { - constructor( - @InjectRepository(CourseRatingEntity) - private readonly ratingRepo: Repository, - @InjectRepository(CourseEntity) - private readonly courseRepo: Repository, - ) {} - - /** - * Submit or update a rating for a course. - * - If the user has not previously rated this course, creates a new rating. - * - If the user has already rated this course, updates the existing rating. - * Returns the saved rating entity. - */ - async submitRating( - courseId: string, - userId: string, - dto: CreateRatingDto, - ): Promise { - // Verify course exists - const course = await this.courseRepo.findOne({ where: { id: courseId } }); - if (!course) { - throw new NotFoundException({ - error: 'COURSE_NOT_FOUND', - message: `Course with ID ${courseId} not found`, - }); - } - - // Check if user already has a rating for this course - let rating = await this.ratingRepo.findOne({ - where: { courseId, userId }, - }); - - if (rating) { - // Update existing rating (upsert) - rating.rating = dto.rating; - rating.review = dto.review ?? null; - rating.updatedAt = new Date(); - } else { - // Create new rating - rating = this.ratingRepo.create({ - courseId, - userId, - rating: dto.rating, - review: dto.review ?? null, - }); - } - - return this.ratingRepo.save(rating); - } - - /** - * Retrieve all ratings for a course with pagination support. - * Returns ratings ordered by most recent first, with reviewer info (no email). - */ - async listRatings( - courseId: string, - page: number = 1, - perPage: number = 20, - ): Promise<{ - data: CourseRatingEntity[]; - total: number; - page: number; - perPage: number; - pages: number; - }> { - // Verify course exists - const course = await this.courseRepo.findOne({ where: { id: courseId } }); - if (!course) { - throw new NotFoundException({ - error: 'COURSE_NOT_FOUND', - message: `Course with ID ${courseId} not found`, - }); - } - - // Validate pagination params - if (page < 1) page = 1; - if (perPage < 1 || perPage > 100) perPage = 20; - - const skip = (page - 1) * perPage; - - const [data, total] = await this.ratingRepo.findAndCount({ - where: { courseId }, - order: { createdAt: 'DESC' }, - skip, - take: perPage, - }); - - const pages = Math.ceil(total / perPage); - - return { data, total, page, perPage, pages }; - } - - /** - * Get aggregated rating statistics for a course. - * Returns average rating (to 2 decimal places), total count, and distribution. - */ - async getRatingStats(courseId: string): Promise { - // Verify course exists - const course = await this.courseRepo.findOne({ where: { id: courseId } }); - if (!course) { - throw new NotFoundException({ - error: 'COURSE_NOT_FOUND', - message: `Course with ID ${courseId} not found`, - }); - } - - // Get all ratings for this course - const ratings = await this.ratingRepo.find({ - where: { courseId }, - }); - - const totalRatings = ratings.length; - const averageRating = - totalRatings > 0 - ? Math.round( - (ratings.reduce((sum, r) => sum + r.rating, 0) / totalRatings) * - 100, - ) / 100 - : 0; - - // Calculate distribution - const distribution: RatingDistributionDto = { - oneStar: ratings.filter((r) => r.rating === 1).length, - twoStar: ratings.filter((r) => r.rating === 2).length, - threeStar: ratings.filter((r) => r.rating === 3).length, - fourStar: ratings.filter((r) => r.rating === 4).length, - fiveStar: ratings.filter((r) => r.rating === 5).length, - }; - - return { - courseId, - averageRating, - totalRatings, - ratingDistribution: distribution, - }; - } - - /** - * Delete a rating submitted by a user for a course. - * Only allows a user to delete their own rating. - * Returns true if a rating was deleted, false if no rating existed. - */ - async deleteRating(courseId: string, userId: string): Promise { - const rating = await this.ratingRepo.findOne({ - where: { courseId, userId }, - }); - - if (!rating) { - // Return 404 without leaking existence - throw new NotFoundException({ - error: 'RATING_NOT_FOUND', - message: 'No rating found for this course', - }); - } - - await this.ratingRepo.remove(rating); - return true; - } - - /** - * Check if a specific user has rated a specific course. - * Used internally for validation. - */ - async hasUserRated(courseId: string, userId: string): Promise { - const rating = await this.ratingRepo.findOne({ - where: { courseId, userId }, - }); - return !!rating; - } - - /** - * Get a specific user's rating for a course (if it exists). - */ - async getUserRating( - courseId: string, - userId: string, - ): Promise { - return this.ratingRepo.findOne({ - where: { courseId, userId }, - }); - } -} diff --git a/BackendAcademy/src/courses/course-revision.entity.ts b/BackendAcademy/src/courses/course-revision.entity.ts deleted file mode 100644 index 58dea2bdc7..0000000000 --- a/BackendAcademy/src/courses/course-revision.entity.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { - Column, - CreateDateColumn, - Entity, - Index, - PrimaryColumn, -} from 'typeorm'; -import { CourseLevel } from './interfaces/course-level.enum'; -import { CourseEntity } from './course.entity'; - -/** - * Append-only, immutable snapshot of a course at a specific version. - * - * `CourseRevisionEntity` is the heart of the course versioning system. Every - * meaningful change to a course (create / update / restore) appends one row - * here. Rows are never updated after creation, so historical revisions - * remain a true audit trail. Of course (`CourseEntity`) → many revisions - * relationship lives on this table. - */ -@Entity({ name: 'course_revisions' }) -@Index(['courseId', 'version'], { unique: true }) -export class CourseRevisionEntity { - @PrimaryColumn('uuid') - id: string; - - @Index('idx_course_revisions_course_id') - @Column({ name: 'course_id', type: 'uuid' }) - courseId: string; - - @Column({ type: 'int' }) - version: number; - - /** - * Immutable JSON snapshot of the editable course fields at this version. - * Stored as `jsonb` so the structure can be queried/audited without an - * additional relation table. - */ - @Column({ type: 'jsonb' }) - snapshot: { - title: string; - description: string; - level: CourseLevel; - order: number; - learningPathId: string; - duration: number; - category: string; - categories: string[]; - tags: string[]; - prerequisites: string[]; - skills: string[]; - xpReward: number; - isActive: boolean; - }; - - /** Optional human-readable summary of what changed */ - @Column({ name: 'change_note', type: 'text', nullable: true }) - changeNote?: string; - - /** Optional author/editor identifier who created this revision */ - @Column({ name: 'revision_author', type: 'varchar', length: 120, nullable: true }) - revisionAuthor?: string; - - /** Reason this revision was created (e.g. 'update', 'restore') */ - @Column({ type: 'varchar', length: 32 }) - reason: CourseRevisionReason; - - /** - * Version that was active immediately before this revision, for traceability. - * Null on the initial 'create' revision. - */ - @Column({ name: 'previous_version', type: 'int', nullable: true }) - previousVersion?: number; - - /** - * Optional pointer to the revision this one was derived from (e.g. a - * `restore` revision that pulled content from a prior version). - */ - @Column({ - name: 'reference_revision_id', - type: 'uuid', - nullable: true, - }) - referenceRevisionId?: string; - - @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) - createdAt: Date; - - /** - * Optional forward relation to the parent course. Modelled as a plain - * indexed column without an FK constraint so the version history can - * outlive a deleted course — the audit trail must remain queryable even - * after the parent `CourseEntity` row is removed. - */ - course?: CourseEntity; - - constructor(partial: Partial = {}) { - Object.assign(this, partial); - this.createdAt = this.createdAt || new Date(); - this.reason = this.reason || 'update'; - } -} - -export type CourseRevisionReason = 'create' | 'update' | 'restore'; diff --git a/BackendAcademy/src/courses/course.controller.ts b/BackendAcademy/src/courses/course.controller.ts deleted file mode 100644 index 063729905b..0000000000 --- a/BackendAcademy/src/courses/course.controller.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { - Controller, - Get, - Post, - Put, - Delete, - Body, - Param, - UseGuards, - Request, - Query, - HttpCode, -} from '@nestjs/common'; -import { CourseService } from './course.service'; -import { CourseRatingService } from './course-rating.service'; -import { CourseRevisionEntity } from './course-revision.entity'; -import { CourseEntity } from './course.entity'; -import { CourseRatingEntity } from './course-rating.entity'; -import { CreateCourseDto } from './dto/create-course.dto'; -import { UpdateCourseDto } from './dto/update-course.dto'; -import { RestoreRevisionDto } from './dto/restore-revision.dto'; -import { CompleteCourseDto } from './dto/complete-course.dto'; -import { CreateRatingDto } from './dto/create-rating.dto'; -import { CourseRatingStatsDto } from './dto/rating-stats.dto'; -import { JwtLearnerGuard } from '../auth/guards/jwt-learner.guard'; -import { JwtPayload } from '../auth/interfaces/jwt-payload.interface'; - -@Controller('courses') -export class CourseController { - constructor( - private readonly courseService: CourseService, - private readonly ratingService: CourseRatingService, - ) {} - - @Post() - async create(@Body() dto: CreateCourseDto) { - return this.courseService.create(dto); - } - - @Get() - async findAll() { - return this.courseService.findAll(); - } - - @Get('level/:level') - async findByLevel(@Param('level') level: string) { - return this.courseService.findByLevel(level); - } - - @Get(':slug') - async findBySlug(@Param('slug') slug: string) { - return this.courseService.findBySlugOrId(slug); - } - - @Put(':id') - async update( - @Param('id') id: string, - @Body() dto: UpdateCourseDto, - ): Promise { - return this.courseService.update(id, dto); - } - - @Delete(':id') - async remove(@Param('id') id: string) { - return this.courseService.remove(id); - } - - @Post(':id/restore') - async restore(@Param('id') id: string) { - return this.courseService.restoreCourse(id); - } - - // --------------------------------------------------------------------------- - // Revision history endpoints - // - // Route ordering note: the explicit `/latest` and `/count` paths are - // declared before the parametric `/revisions/:version` so Express / Nest - // matches them first. Moving them below would break the lookup behavior. - // --------------------------------------------------------------------------- - - @Get(':id/revisions') - async listRevisions( - @Param('id') id: string, - ): Promise { - return this.courseService.getRevisions(id); - } - - @Get(':id/revisions/latest') - async getLatestRevision( - @Param('id') id: string, - ): Promise { - return this.courseService.getLatestRevision(id); - } - - @Get(':id/revisions/count') - async getRevisionCount( - @Param('id') id: string, - ): Promise<{ count: number }> { - const count = await this.courseService.getRevisionCount(id); - return { count }; - } - - @Get(':id/revisions/:version') - async getRevision( - @Param('id') id: string, - @Param('version') version: string, - ): Promise { - return this.courseService.getRevisionByVersion(id, Number(version)); - } - - @Post(':id/revisions/:version/restore') - async restoreRevision( - @Param('id') id: string, - @Param('version') version: string, - @Body() dto: RestoreRevisionDto, - ): Promise { - return this.courseService.restoreRevision( - id, - Number(version), - dto.revisionAuthor, - ); - } - - @Post(':id/complete') - async complete(@Param('id') id: string, @Body() dto: CompleteCourseDto) { - return this.courseService.completeCourse(id, dto.userId); - } - - // --------------------------------------------------------------------------- - // Course ratings endpoints - // --------------------------------------------------------------------------- - - /** - * Submit or update a rating for a course. - * Requires authentication. If user already rated this course, their rating is updated (upsert). - * Returns 201 Created on first submission, 200 OK on update. - */ - @Post(':id/ratings') - @UseGuards(JwtLearnerGuard) - async submitRating( - @Param('id') courseId: string, - @Body() dto: CreateRatingDto, - @Request() req: Express.Request & { user: JwtPayload }, - ): Promise { - const userId = req.user.sub; - return this.ratingService.submitRating(courseId, userId, dto); - } - - /** - * List all ratings for a course with pagination support. - * Public endpoint (no auth required). - * Supports ?page=1&per_page=20 query params. - */ - @Get(':id/ratings') - async listRatings( - @Param('id') courseId: string, - @Query('page') page?: string, - @Query('per_page') perPage?: string, - ) { - const pageNum = page ? parseInt(page, 10) : 1; - const perPageNum = perPage ? parseInt(perPage, 10) : 20; - return this.ratingService.listRatings(courseId, pageNum, perPageNum); - } - - /** - * Get aggregated rating statistics for a course. - * Public endpoint (no auth required). - * Returns average rating (2 decimal places), total count, and star distribution. - */ - @Get(':id/ratings/stats') - async getRatingStats(@Param('id') courseId: string): Promise { - return this.ratingService.getRatingStats(courseId); - } - - /** - * Delete a user's rating for a course. - * Requires authentication. Only allows deleting own rating. - * Returns 204 No Content on success. - */ - @Delete(':id/ratings') - @HttpCode(204) - @UseGuards(JwtLearnerGuard) - async deleteRating( - @Param('id') courseId: string, - @Request() req: Express.Request & { user: JwtPayload }, - ): Promise { - const userId = req.user.sub; - await this.ratingService.deleteRating(courseId, userId); - } -} diff --git a/BackendAcademy/src/courses/course.entity.ts b/BackendAcademy/src/courses/course.entity.ts deleted file mode 100644 index e3d74bda0c..0000000000 --- a/BackendAcademy/src/courses/course.entity.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { - Column, - CreateDateColumn, - Entity, - Index, - OneToMany, - PrimaryColumn, - UpdateDateColumn, -} from 'typeorm'; -import { CourseLevel } from './interfaces/course-level.enum'; -import { CourseRevisionEntity } from './course-revision.entity'; - -/** - * Postgres-backed Course entity. - * - * `CourseEntity` is the canonical, mutable representation of a course that is - * currently being served to learners. Every update to the course should also - * append an immutable entry to the `course_revisions` table (see - * `CourseRevisionEntity`) so the full version history is preserved. - */ -@Entity({ name: 'courses' }) -export class CourseEntity { - @PrimaryColumn('uuid') - id: string; - - @Column({ type: 'varchar', length: 200 }) - title: string; - - @Index('uq_courses_slug', { unique: true }) - @Column({ type: 'varchar', length: 240 }) - slug: string; - - @Column({ type: 'text' }) - description: string; - - @Column({ type: 'enum', enum: CourseLevel }) - level: CourseLevel; - - @Column({ type: 'int' }) - order: number; - - @Index('idx_courses_learning_path_id') - @Column({ name: 'learning_path_id', type: 'uuid' }) - learningPathId: string; - - @Column({ type: 'int' }) - duration: number; - - @Column({ type: 'varchar', length: 120, default: 'general' }) - category: string; - - @Column({ type: 'text', array: true, default: () => "'{}'" }) - categories: string[]; - - @Column({ type: 'text', array: true, default: () => "'{}'" }) - tags: string[]; - - @Column({ type: 'text', array: true, default: () => "'{}'" }) - prerequisites: string[]; - - @Column({ type: 'text', array: true, default: () => "'{}'" }) - skills: string[]; - - @Column({ name: 'xp_reward', type: 'int', default: 0 }) - xpReward: number; - - @Column({ name: 'is_active', type: 'boolean', default: true }) - isActive: boolean; - - /** - * Monotonically increasing version that mirrors the latest revision's - * version. Kept on the row itself so callers can compare current state - * to a known revision without an extra join. - */ - @Column({ type: 'int', default: 1 }) - version: number; - - /** - * Pointer to the most recent `CourseRevisionEntity.id` for this course. - * Modelled as a plain nullable UUID column to avoid circular foreign-key - * constraints during inserts. - */ - @Column({ - name: 'latest_revision_id', - type: 'uuid', - nullable: true, - }) - latestRevisionId?: string; - - @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) - createdAt: Date; - - @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) - updatedAt: Date; - - /** - * Reverse relation: the full version history for this course. Not a - * database column — TypeORM populates it from the `course_revisions` table - * when explicitly queried. - */ - @OneToMany(() => CourseRevisionEntity, (revision) => revision.course) - revisions?: CourseRevisionEntity[]; - - constructor(partial: Partial = {}) { - Object.assign(this, partial); - this.createdAt = this.createdAt || new Date(); - this.updatedAt = this.updatedAt || new Date(); - this.isActive = this.isActive ?? true; - this.category = this.category || this.categories?.[0] || 'general'; - this.categories = this.categories || [this.category]; - this.tags = this.tags || []; - this.prerequisites = this.prerequisites || []; - this.skills = this.skills || []; - // Object.assign above already copies version; default to 1 when absent. - this.version ??= 1; - } -} diff --git a/BackendAcademy/src/courses/course.module.ts b/BackendAcademy/src/courses/course.module.ts deleted file mode 100644 index 3387b872c9..0000000000 --- a/BackendAcademy/src/courses/course.module.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { CourseController } from './course.controller'; -import { CourseService } from './course.service'; -import { CourseRatingService } from './course-rating.service'; -import { CertificateService } from './certificate.service'; -import { CourseEntity } from './course.entity'; -import { CourseRevisionEntity } from './course-revision.entity'; -import { CourseRatingEntity } from './course-rating.entity'; -import { RewardsModule } from '../rewards/rewards.module'; -import { TransactionManagerService } from '../common/transaction-manager.service'; -import { ConfigModule } from '@nestjs/config'; -import { SearchModule } from '../search/search.module'; -import { RedisModule } from '../redis/redis.module'; - -@Module({ - imports: [ - ConfigModule, - TypeOrmModule.forFeature([ - CourseEntity, - CourseRevisionEntity, - CourseRatingEntity, - ]), - RewardsModule, - SearchModule, - RedisModule, - ], - controllers: [CourseController], - providers: [ - CourseService, - CourseRatingService, - CertificateService, - TransactionManagerService, - ], - exports: [CourseService, CourseRatingService, CertificateService], -}) -export class CourseModule {} diff --git a/BackendAcademy/src/courses/course.service.spec.ts b/BackendAcademy/src/courses/course.service.spec.ts deleted file mode 100644 index edabd258ac..0000000000 --- a/BackendAcademy/src/courses/course.service.spec.ts +++ /dev/null @@ -1,635 +0,0 @@ -import { NotFoundException } from '@nestjs/common'; -import { CourseService } from './course.service'; -import { CourseEntity } from './course.entity'; -import { CourseRevisionEntity } from './course-revision.entity'; -import { CourseLevel } from './interfaces/course-level.enum'; -import { TransactionManagerService } from '../common/transaction-manager.service'; - -import { RewardsService } from '../rewards/rewards.service'; - -/** - * Minimal in-memory mock that imitates the subset of the - * `Repository` surface that `CourseService` relies on. Tests construct - * a fresh mock per `beforeEach` so the service runs in isolation. - * - * `create()` invokes the entity constructor (mirroring real TypeORM - * behaviour) so defaults declared in the constructor — e.g. - * `isActive ??= true` — still apply to rows stored by the mock. - */ -class InMemoryRepository { - protected readonly rows: Map = new Map(); - - constructor( - protected readonly EntityCtor?: new (partial?: Partial) => T, - ) {} - - create(partial: Partial = {}): T { - if (this.EntityCtor) { - return new this.EntityCtor(partial); - } - return { ...(partial as T) }; - } - - async save(entity: T): Promise { - if (!entity.id) { - (entity as T & { id: string }).id = crypto.randomUUID(); - } - const now = new Date(); - if ('createdAt' in entity && !(entity as { createdAt?: Date }).createdAt) { - (entity as { createdAt: Date }).createdAt = now; - } - if ('updatedAt' in entity) { - (entity as { updatedAt: Date }).updatedAt = now; - } - this.rows.set((entity as T & { id: string }).id, entity); - return entity; - } - - async find(options: { where?: Partial; order?: { version?: 'ASC' | 'DESC' } } = {}): Promise { - const matches = Object.values(this.matchRows(options.where ?? {})); - if (options.order?.version) { - matches.sort((a, b) => { - const av = (a as unknown as { version: number }).version; - const bv = (b as unknown as { version: number }).version; - return options.order!.version === 'ASC' ? av - bv : bv - av; - }); - } - return matches; - } - - async findOne(options: { where: Partial; order?: { version?: 'ASC' | 'DESC' } }): Promise { - const [first] = Object.values(this.matchRows(options.where)); - if (first && options.order?.version) { - const all = Object.values(this.matchRows(options.where)); - return all.sort((a, b) => { - const av = (a as unknown as { version: number }).version; - const bv = (b as unknown as { version: number }).version; - return options.order!.version === 'ASC' ? av - bv : bv - av; - })[0]; - } - return first ?? null; - } - - async remove(entity: T): Promise { - this.rows.delete((entity as T & { id: string }).id); - return entity; - } - - async count(options: { where?: Partial } = {}): Promise { - return Object.values(this.matchRows(options.where ?? {})).length; - } - - private matchRows(where: Partial): Record { - const matches: Record = {}; - for (const [id, row] of this.rows.entries()) { - const ok = Object.entries(where as Record).every( - ([key, expected]) => { - const actual = (row as Record)[key]; - if (Array.isArray(expected)) { - return Array.isArray(actual) && - expected.length === actual.length && - expected.every((v, i) => v === (actual as unknown[])[i]); - } - return actual === expected; - }, - ); - if (ok) matches[id] = row; - } - return matches; - } -} - -class InMemoryCourseRepo extends InMemoryRepository { - constructor() { - super(CourseEntity); - } -} -class InMemoryRevisionRepo extends InMemoryRepository { - constructor() { - super(CourseRevisionEntity); - } -} - -describe('CourseService', () => { - let service: CourseService; - let courseRepo: InMemoryCourseRepo; - let revisionRepo: InMemoryRevisionRepo; - let rewardsService: RewardsService; - let searchIndexer: { indexCourse: jest.Mock; removeCourse: jest.Mock }; - let redisService: { invalidateContentCache: jest.Mock }; - - beforeEach(() => { - courseRepo = new InMemoryCourseRepo(); - revisionRepo = new InMemoryRevisionRepo(); - rewardsService = { - recordActivity: jest.fn(), - } as unknown as RewardsService; - searchIndexer = { - indexCourse: jest.fn(), - removeCourse: jest.fn(), - }; - redisService = { - invalidateContentCache: jest.fn().mockResolvedValue(0), - }; - service = new CourseService( - courseRepo as unknown as import('typeorm').Repository, - revisionRepo as unknown as import('typeorm').Repository, - rewardsService, - new TransactionManagerService(), - undefined as any, - undefined, - searchIndexer as any, - redisService as any, - ); - }); - - // --------------------------------------------------------------------------- - // Soft-delete and restore lifecycle (#352) - // --------------------------------------------------------------------------- - - it('soft-deletes a course by marking it inactive instead of removing the row', async () => { - const course = await service.create({ - title: 'SoftDel', - description: 'Desc', - level: CourseLevel.BEGINNER, - order: 1, - learningPathId: 'path-1', - duration: 30, - }); - - const removed = await service.remove(course.id); - expect(removed).toBe(true); - - // Course is inactive but still findable by id - const fetched = await service.findById(course.id); - expect(fetched).not.toBeNull(); - expect(fetched!.isActive).toBe(false); - - // Does NOT appear in active-only listings - const all = await service.findAll(); - expect(all.map((c) => c.id)).not.toContain(course.id); - - // Revision history is preserved - const revisions = await service.getRevisions(course.id); - expect(revisions.length).toBeGreaterThanOrEqual(2); - expect(revisions[revisions.length - 1].reason).toBe('update'); - expect(revisions[revisions.length - 1].changeNote).toBe('Course soft-deleted'); - }); - - it('restores a soft-deleted course and records a restore revision', async () => { - const course = await service.create({ - title: 'Restorable', - description: 'Desc', - level: CourseLevel.BEGINNER, - order: 1, - learningPathId: 'path-1', - duration: 30, - }); - - await service.remove(course.id); - const restored = await service.restoreCourse(course.id); - - expect(restored).not.toBeNull(); - expect(restored!.isActive).toBe(true); - expect(restored!.version).toBe(3); // create + soft-delete + restore - - const all = await service.findAll(); - expect(all.map((c) => c.id)).toContain(course.id); - - const revisions = await service.getRevisions(course.id); - expect(revisions[revisions.length - 1].reason).toBe('restore'); - expect(revisions[revisions.length - 1].changeNote).toBe('Course restored from soft-delete'); - }); - - it('returns null when restoring a non-existent course', async () => { - const result = await service.restoreCourse('ghost-course'); - expect(result).toBeNull(); - }); - - // --------------------------------------------------------------------------- - // Baseline CRUD behavior (unchanged from pre-versioning baseline) - // --------------------------------------------------------------------------- - - it('creates a course at version 1 with an initial revision', async () => { - const course = await service.create({ - title: 'Rust 101', - description: 'Intro to Rust', - level: CourseLevel.BEGINNER, - order: 1, - learningPathId: 'path-rust', - duration: 60, - xpReward: 50, - }); - - expect(course.id).toBeDefined(); - expect(course.version).toBe(1); - expect(course.latestRevisionId).toBeDefined(); - - const revisions = await service.getRevisions(course.id); - expect(revisions).toHaveLength(1); - expect(revisions[0].version).toBe(1); - expect(revisions[0].reason).toBe('create'); - expect(revisions[0].snapshot.title).toBe('Rust 101'); - }); - - it('generates normalized unique slugs on create', async () => { - const first = await service.create({ - title: 'Rust 101: Ownership & Borrowing!', - description: 'Intro to Rust', - level: CourseLevel.BEGINNER, - order: 1, - learningPathId: 'path-rust', - duration: 60, - }); - const second = await service.create({ - title: 'Rust 101 Ownership Borrowing', - description: 'Another course', - level: CourseLevel.BEGINNER, - order: 2, - learningPathId: 'path-rust', - duration: 60, - }); - - expect(first.slug).toBe('rust-101-ownership-borrowing'); - expect(second.slug).toBe('rust-101-ownership-borrowing-2'); - expect(await service.findBySlugOrId(first.slug)).toBe(first); - }); - - it('regenerates the slug when a title changes', async () => { - const course = await service.create({ - title: 'Original Title', - description: 'Desc', - level: CourseLevel.BEGINNER, - order: 1, - learningPathId: 'path-1', - duration: 30, - }); - - const updated = await service.update(course.id, { title: 'A New Title' }); - - expect(updated!.slug).toBe('a-new-title'); - expect(await service.findBySlugOrId('original-title')).toBeNull(); - expect(await service.findBySlugOrId('a-new-title')).toBe(updated); - }); - - it('returns only active courses from findAll()', async () => { - const active = await service.create({ - title: 'Active', - description: 'Active course', - level: CourseLevel.BEGINNER, - order: 1, - learningPathId: 'path-1', - duration: 30, - }); - const inactive = await service.create({ - title: 'Inactive', - description: 'Draft course', - level: CourseLevel.BEGINNER, - order: 2, - learningPathId: 'path-1', - duration: 30, - }); - await service.update(inactive.id, { isActive: false }); - - const all = await service.findAll(); - expect(all.map((c) => c.id)).toEqual([active.id]); - }); - - // --------------------------------------------------------------------------- - // Versioning on update - // --------------------------------------------------------------------------- - - it('increments version and appends a revision on each update', async () => { - const course = await service.create({ - title: 'Title v1', - description: 'Desc v1', - level: CourseLevel.BEGINNER, - order: 1, - learningPathId: 'path-1', - duration: 30, - }); - expect(course.version).toBe(1); - - const updated = await service.update(course.id, { - title: 'Title v2', - changeNote: 'Tightened wording', - revisionAuthor: 'editor-1', - }); - expect(updated).not.toBeNull(); - expect(updated!.version).toBe(2); - expect(updated!.title).toBe('Title v2'); - expect(updated!.latestRevisionId).toBeDefined(); - - const updated2 = await service.update(course.id, { - description: 'Desc v3', - changeNote: 'Expanded goals', - revisionAuthor: 'editor-2', - }); - expect(updated2!.version).toBe(3); - - const revisions = await service.getRevisions(course.id); - expect(revisions.map((r) => r.version)).toEqual([1, 2, 3]); - expect(revisions[1].changeNote).toBe('Tightened wording'); - expect(revisions[1].revisionAuthor).toBe('editor-1'); - expect(revisions[1].reason).toBe('update'); - expect(revisions[1].previousVersion).toBe(1); - expect(revisions[2].snapshot.title).toBe('Title v2'); // carries forward previous edits - expect(revisions[2].snapshot.description).toBe('Desc v3'); - }); - - it('updates latestRevisionId to point at the most recent revision', async () => { - const course = await service.create({ - title: 'Token Test', - description: 'Test', - level: CourseLevel.BEGINNER, - order: 1, - learningPathId: 'path-1', - duration: 30, - }); - const firstLatestId = course.latestRevisionId; - - const updated = await service.update(course.id, { title: 'Token Test 2' }); - expect(updated!.latestRevisionId).not.toEqual(firstLatestId); - - const latest = await service.getLatestRevision(course.id); - expect(latest).not.toBeNull(); - expect(latest!.id).toBe(updated!.latestRevisionId); - expect(latest!.snapshot.title).toBe('Token Test 2'); - }); - - it('preserves a deep copy of the snapshot so later updates do not mutate history', async () => { - const course = await service.create({ - title: 'Snapshot Test', - description: 'Desc', - level: CourseLevel.BEGINNER, - order: 1, - learningPathId: 'path-1', - duration: 30, - prerequisites: ['rust-basics'], - }); - await service.update(course.id, { - prerequisites: ['rust-basics', 'ownership'], - skills: ['borrowing'], - }); - - const revisions = await service.getRevisions(course.id); - expect(revisions[0].snapshot.prerequisites).toEqual(['rust-basics']); - expect(revisions[1].snapshot.prerequisites).toEqual([ - 'rust-basics', - 'ownership', - ]); - }); - - // --------------------------------------------------------------------------- - // --------------------------------------------------------------------------- - // Taxonomy normalization (BA-047) - // --------------------------------------------------------------------------- - - it('normalizes free-text fields on create', async () => { - const course = await service.create({ - title: ' Rust Basics ', - description: ' An intro to Rust. ', - level: 'INTERMEDIATE' as CourseLevel, - order: 1, - learningPathId: 'path-1', - duration: 30, - }); - - expect(course.title).toBe('Rust Basics'); - expect(course.description).toBe('An intro to Rust.'); - expect(course.level).toBe(CourseLevel.INTERMEDIATE); - expect(course.slug).toBe('rust-basics'); - }); - - it('canonicalizes taxonomy arrays (trim, lowercase, dedupe, drop blanks) on create', async () => { - const course = await service.create({ - title: 'Rust Basics', - description: 'An intro', - level: CourseLevel.BEGINNER, - order: 1, - learningPathId: 'path-1', - duration: 30, - category: 'WASM', - categories: ['WASM', ' Rust ', 'Rust', ' '], - tags: [' Ownership ', 'ownership'], - prerequisites: [' borrowed ', 'lifetimes'], - skills: ['Memory Safety', 'memory safety'], - }); - - expect(course.category).toBe('wasm'); - expect(course.categories).toEqual(['wasm', 'rust']); - expect(course.tags).toEqual(['ownership']); - expect(course.prerequisites).toEqual(['borrowed', 'lifetimes']); - expect(course.skills).toEqual(['memory safety']); - }); - - it('normalizes taxonomy fields on update only when provided', async () => { - const course = await service.create({ - title: 'Rust Basics', - description: 'An intro', - level: CourseLevel.BEGINNER, - order: 1, - learningPathId: 'path-1', - duration: 30, - tags: [' original '], - prerequisites: ['stay'], - }); - - const updated = await service.update(course.id, { skills: [' WASM ', 'wasm'] }); - - // Fields that were not part of the update payload must be untouched. - expect(updated!.tags).toEqual(['original']); - expect(updated!.prerequisites).toEqual(['stay']); - // Provided taxonomy is canonicalized. - expect(updated!.skills).toEqual(['wasm']); - }); - - it('keeps the canonical title bound reflected in the persisted slug on update', async () => { - const course = await service.create({ - title: 'Rust Basics', - description: 'An intro', - level: CourseLevel.BEGINNER, - order: 1, - learningPathId: 'path-1', - duration: 30, - }); - - const updated = await service.update(course.id, { title: ' Rust Advanced ' }); - expect(updated!.title).toBe('Rust Advanced'); - expect(updated!.slug).toBe('rust-advanced'); - }); - - // Revision lookup - // --------------------------------------------------------------------------- - - it('returns the correct revision by version', async () => { - const course = await service.create({ - title: 'Lookup', - description: 'Desc', - level: CourseLevel.BEGINNER, - order: 1, - learningPathId: 'path-1', - duration: 30, - }); - await service.update(course.id, { title: 'Lookup v2' }); - - const v1 = await service.getRevisionByVersion(course.id, 1); - const v2 = await service.getRevisionByVersion(course.id, 2); - - expect(v1).not.toBeNull(); - expect(v1!.snapshot.title).toBe('Lookup'); - expect(v2).not.toBeNull(); - expect(v2!.snapshot.title).toBe('Lookup v2'); - }); - - it('returns null for a missing revision version', async () => { - const course = await service.create({ - title: 'MissingVersion', - description: 'Desc', - level: CourseLevel.BEGINNER, - order: 1, - learningPathId: 'path-1', - duration: 30, - }); - const result = await service.getRevisionByVersion(course.id, 99); - expect(result).toBeNull(); - }); - - it('returns an empty array when listing revisions for an unknown course', async () => { - const revisions = await service.getRevisions('non-existent'); - expect(revisions).toEqual([]); - expect(await service.getRevisionByVersion('non-existent', 1)).toBeNull(); - expect(await service.getRevisionCount('non-existent')).toBe(0); - expect(await service.getLatestRevision('non-existent')).toBeNull(); - }); - - it('throws NotFoundException for non-positive revision versions', async () => { - const course = await service.create({ - title: 'BadVersion', - description: 'Desc', - level: CourseLevel.BEGINNER, - order: 1, - learningPathId: 'path-1', - duration: 30, - }); - await expect(service.getRevisionByVersion(course.id, 0)).rejects.toThrow( - NotFoundException, - ); - await expect(service.getRevisionByVersion(course.id, -1)).rejects.toThrow( - NotFoundException, - ); - }); - - it('counts the number of revisions correctly', async () => { - const course = await service.create({ - title: 'Counter', - description: 'Desc', - level: CourseLevel.BEGINNER, - order: 1, - learningPathId: 'path-1', - duration: 30, - }); - expect(await service.getRevisionCount(course.id)).toBe(1); - await service.update(course.id, { title: 'Counter v2' }); - expect(await service.getRevisionCount(course.id)).toBe(2); - }); - - // --------------------------------------------------------------------------- - // Restore behavior - // --------------------------------------------------------------------------- - - it('restores a course to a previous version and records a new revision', async () => { - const course = await service.create({ - title: 'Original', - description: 'Original desc', - level: CourseLevel.BEGINNER, - order: 1, - learningPathId: 'path-1', - duration: 30, - prerequisites: [], - }); - await service.update(course.id, { - title: 'Second', - description: 'Second desc', - prerequisites: ['pre-1'], - }); - await service.update(course.id, { - title: 'Third', - description: 'Third desc', - prerequisites: ['pre-1', 'pre-2'], - }); - - const restored = await service.restoreRevision( - course.id, - 1, - 'editor-restore', - ); - expect(restored).not.toBeNull(); - expect(restored!.title).toBe('Original'); - expect(restored!.description).toBe('Original desc'); - expect(restored!.prerequisites).toEqual([]); - // Restoring bumps the version forward (append-only history) - expect(restored!.version).toBe(4); - - const revisions = await service.getRevisions(course.id); - expect(revisions.map((r) => r.version)).toEqual([1, 2, 3, 4]); - expect(revisions[3].reason).toBe('restore'); - expect(revisions[3].changeNote).toBe('Restored from version 1'); - expect(revisions[3].revisionAuthor).toBe('editor-restore'); - expect(revisions[3].previousVersion).toBe(3); - expect(revisions[3].referenceRevisionId).toBe(revisions[0].id); - }); - - it('throws NotFoundException when restoring a course that does not exist', async () => { - await expect(service.restoreRevision('ghost-course', 1)).rejects.toThrow( - NotFoundException, - ); - }); - - it('throws NotFoundException when restoring from a non-existent revision', async () => { - const course = await service.create({ - title: 'RestoreFail', - description: 'Desc', - level: CourseLevel.BEGINNER, - order: 1, - learningPathId: 'path-1', - duration: 30, - }); - await expect(service.restoreRevision(course.id, 99)).rejects.toThrow( - NotFoundException, - ); - }); - - // --------------------------------------------------------------------------- - // Removal preserves revision history - // --------------------------------------------------------------------------- - - it('soft-deletes a course and keeps its revision history queryable for audit', async () => { - const course = await service.create({ - title: 'ToDelete', - description: 'Desc', - level: CourseLevel.BEGINNER, - order: 1, - learningPathId: 'path-1', - duration: 30, - }); - - expect(await service.remove(course.id)).toBe(true); - - // The course is inactive but still present (soft-delete) - const fetched = await service.findById(course.id); - expect(fetched).not.toBeNull(); - expect(fetched!.isActive).toBe(false); - - // Revision history is preserved — both the initial create and - // the soft-delete revision exist. - const revisions = await service.getRevisions(course.id); - expect(revisions).toHaveLength(2); - expect(revisions[0].snapshot.title).toBe('ToDelete'); - expect(revisions[0].reason).toBe('create'); - expect(revisions[1].reason).toBe('update'); - expect(revisions[1].changeNote).toBe('Course soft-deleted'); - expect(await service.getRevisionByVersion(course.id, 1)).not.toBeNull(); - expect(await service.getRevisionCount(course.id)).toBe(2); - }); -}); \ No newline at end of file diff --git a/BackendAcademy/src/courses/course.service.ts b/BackendAcademy/src/courses/course.service.ts deleted file mode 100644 index 285b833107..0000000000 --- a/BackendAcademy/src/courses/course.service.ts +++ /dev/null @@ -1,749 +0,0 @@ -import { Injectable, Logger, NotFoundException, Optional, ConflictException } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { CourseEntity } from './course.entity'; -import { CourseLevel } from './interfaces/course-level.enum'; -import { - CourseRevisionEntity, - CourseRevisionReason, -} from './course-revision.entity'; -import { CreateCourseDto } from './dto/create-course.dto'; -import { UpdateCourseDto } from './dto/update-course.dto'; -import { RewardsService } from '../rewards/rewards.service'; -import { IContractAdapter } from '../contracts'; -import { - TransactionManagerService, - TransactionSnapshot, -} from '../common/transaction-manager.service'; -import { CertificateService, CertificateRecord } from './certificate.service'; -import { SearchIndexerService } from '../search/search-indexer.service'; -import { RedisService } from '../redis/redis.service'; - -/** - * Business logic for courses. - * - * Persistence is delegated to injected TypeORM repositories - * (`Repository` and `Repository`). - * Each meaningful course change appends an immutable revision to the - * `course_revisions` table so the full version history is preserved as - * an append-only audit trail. - * - * #396: Contract operations (reward recording, certificate minting) - * are isolated behind the {@link IContractAdapter} interface rather than - * being tightly coupled to contract-specific logic. When the adapter is - * not available (e.g., in test environments), contract operations are - * gracefully skipped. - * - * #358: All state-mutating operations during course completion are wrapped - * in transactional atomic operations. If any side-effect fails, every - * previously-successful mutation is rolled back so callers never observe - * partially-applied completion state. - */ -@Injectable() -export class CourseService { - private static readonly INITIAL_VERSION = 1; - private readonly logger = new Logger(CourseService.name); - - /** #356: In-flight enrollment locks to prevent duplicate subscriptions. */ - private readonly enrollmentLocks = new Set(); - - /** #356: Active enrollments: key = "userId:courseId". */ - private readonly enrollments = new Map(); - - constructor( - @InjectRepository(CourseEntity) - private readonly courseRepo: Repository, - @InjectRepository(CourseRevisionEntity) - private readonly revisionRepo: Repository, - private readonly rewardsService: RewardsService, - private readonly transactionManager: TransactionManagerService, - private readonly certificateService: CertificateService, - @Optional() - private readonly contractAdapter?: IContractAdapter, - @Optional() - private readonly searchIndexer?: SearchIndexerService, - @Optional() - private readonly redisService?: RedisService, - ) {} - - /** - * #449: Course and initial revision are created atomically. If the - * revision append fails the course row is rolled back so callers never - * observe a course without an audit trail entry. - */ - async create(dto: CreateCourseDto): Promise { - // BA-047: bound + canonicalize taxonomy before anything is persisted. - const normalized = this.normalizeCourseInput(dto); - const slug = await this.createUniqueSlug(normalized.title ?? dto.title); - const course = this.courseRepo.create({ - id: crypto.randomUUID(), - version: CourseService.INITIAL_VERSION, - slug, - ...normalized, - }); - - const txResult = await this.transactionManager.runAtomic(async (tx) => { - const saved = await this.courseRepo.save(course); - - // Rollback: remove orphaned course if revision creation fails - await tx.addOperation(async (): Promise => ({ - restore: () => { - this.courseRepo.remove(saved); - }, - data: { courseId: saved.id }, - })); - - await this.appendRevision(saved, 'create', { - changeNote: 'Initial version', - }); - - return saved; - }); - - if (!txResult.success) { - throw txResult.error; - } - - this.notifyContentChanged(txResult.result!, 'create'); - return txResult.result!; - } - - async findAll(): Promise { - return this.courseRepo.find({ where: { isActive: true } }); - } - - async findByLevel(level: string): Promise { - return this.courseRepo.find({ - where: { isActive: true, level: level as CourseEntity['level'] }, - }); - } - - async findById(id: string): Promise { - return this.courseRepo.findOne({ where: { id } }); - } - - async findBySlugOrId(slugOrId: string): Promise { - const bySlug = await this.courseRepo.findOne({ where: { slug: slugOrId } }); - return bySlug ?? this.findById(slugOrId); - } - - async update(id: string, dto: UpdateCourseDto): Promise { - const course = await this.courseRepo.findOne({ where: { id } }); - if (!course) return null; - - // BA-047: bound + canonicalize taxonomy so only canonical values persist. - const normalized = this.normalizeCourseInput(dto); - - const previousVersion = course.version; - course.version = previousVersion + 1; - course.updatedAt = new Date(); - Object.assign(course, normalized); - if (normalized.title !== undefined) { - course.slug = await this.createUniqueSlug(normalized.title, course.id); - } - this.syncCourseTaxonomy(course, normalized); - const saved = await this.courseRepo.save(course); - - // #449: Rollback the course update if the revision append fails - const txResult = await this.transactionManager.runAtomic(async (tx) => { - // Snapshot of pre-mutation state for rollback - const originalVersion = previousVersion; - const originalUpdatedAt = course.updatedAt; - const originalSnapshot = { ...course }; - - await tx.addOperation(async (): Promise => ({ - restore: () => { - Object.assign(course, originalSnapshot); - course.version = originalVersion; - course.updatedAt = originalUpdatedAt; - this.courseRepo.save(course); - }, - data: { courseId: id, previousVersion }, - })); - - await this.appendRevision(saved, 'update', { - changeNote: dto.changeNote, - revisionAuthor: dto.revisionAuthor, - previousVersion, - }); - - return saved; - }); - - if (!txResult.success) { - throw txResult.error; - } - - this.notifyContentChanged(txResult.result!, 'update'); - return txResult.result!; - } - - /** - * #352: Soft-delete a course by marking it inactive rather than - * removing the row. Hard deletion would break enrollments, - * revisions, certificates, and search history. - */ - async remove(id: string): Promise { - const course = await this.courseRepo.findOne({ where: { id } }); - if (!course) return false; - - const previousVersion = course.version; - course.isActive = false; - course.version = previousVersion + 1; - course.updatedAt = new Date(); - const saved = await this.courseRepo.save(course); - - await this.appendRevision(saved, 'update', { - changeNote: 'Course soft-deleted', - previousVersion, - }); - - // #369: keep the search index in sync with removals - this.searchIndexer?.removeCourse(id); - // #379: drop any cached entries derived from this course - await this.redisService?.invalidateContentCache('course', id); - - this.notifyContentChanged(saved, 'update'); - return true; - } - - /** - * #352: Restore a soft-deleted course. The restore is recorded as - * a new revision so the lifecycle is fully auditable. - */ - async restoreCourse(id: string): Promise { - const course = await this.courseRepo.findOne({ where: { id } }); - if (!course) return null; - - const previousVersion = course.version; - course.isActive = true; - course.version = previousVersion + 1; - course.updatedAt = new Date(); - const saved = await this.courseRepo.save(course); - - await this.appendRevision(saved, 'restore', { - changeNote: 'Course restored from soft-delete', - previousVersion, - }); - - this.notifyContentChanged(saved, 'restore'); - return saved; - } - - /** - * Notify downstream consumers (search index, Redis cache) that a course - * has been created, updated, or restored. Failures here are logged but - * never bubble up — a transient indexer/cache miss must not break the - * primary write path. - */ - private notifyContentChanged(course: CourseEntity, _op: 'create' | 'update' | 'restore'): void { - try { - this.searchIndexer?.indexCourse(course); - } catch (err) { - this.logger.warn( - `[#369] search index update failed for course ${course.id}: ${(err as Error).message}`, - ); - } - // Fire-and-forget the cache invalidation so we don't block the write. - // Swallow async rejections explicitly to keep them out of the - // unhandled-rejection log. - const invalidation = this.redisService?.invalidateContentCache('course', course.id); - if (invalidation && typeof invalidation.catch === 'function') { - invalidation.catch((err: Error) => { - this.logger.warn( - `[#379] redis cache invalidation failed for course ${course.id}: ${err.message}`, - ); - }); - } - } - - // ────────────────────────────────────────────────────────────────── - // Revision history API - // ────────────────────────────────────────────────────────────────── - - async getRevisions(courseId: string): Promise { - return this.revisionRepo.find({ - where: { courseId }, - order: { version: 'ASC' }, - }); - } - - async getLatestRevision( - courseId: string, - ): Promise { - return this.revisionRepo.findOne({ - where: { courseId }, - order: { version: 'DESC' }, - }); - } - - async getRevisionByVersion( - courseId: string, - version: number, - ): Promise { - if (!Number.isFinite(version) || version < 1) { - throw new NotFoundException({ - error: 'INVALID_VERSION', - message: `Version must be a positive integer`, - }); - } - return this.revisionRepo.findOne({ where: { courseId, version } }); - } - - async restoreRevision( - courseId: string, - version: number, - revisionAuthor?: string, - ): Promise { - const course = await this.courseRepo.findOne({ where: { id: courseId } }); - if (!course) { - throw new NotFoundException({ - error: 'COURSE_NOT_FOUND', - message: `Course with ID ${courseId} not found`, - }); - } - - const sourceRevision = await this.getRevisionByVersion(courseId, version); - if (!sourceRevision) { - throw new NotFoundException({ - error: 'REVISION_NOT_FOUND', - message: `Revision ${version} not found for course ${courseId}`, - }); - } - - const previousVersion = course.version; - const target = sourceRevision.snapshot; - course.title = target.title; - course.description = target.description; - course.level = target.level; - course.order = target.order; - course.learningPathId = target.learningPathId; - course.duration = target.duration; - course.category = target.category; - course.categories = [...(target.categories ?? [])]; - course.tags = [...(target.tags ?? [])]; - course.prerequisites = [...target.prerequisites]; - course.skills = [...target.skills]; - course.xpReward = target.xpReward; - course.isActive = target.isActive; - course.version = previousVersion + 1; - course.updatedAt = new Date(); - - const saved = await this.courseRepo.save(course); - await this.appendRevision(saved, 'restore', { - changeNote: `Restored from version ${version}`, - revisionAuthor, - previousVersion, - referenceRevisionId: sourceRevision.id, - }); - this.notifyContentChanged(saved, 'restore'); - return saved; - } - - async getRevisionCount(courseId: string): Promise { - return this.revisionRepo.count({ where: { courseId } }); - } - - // ────────────────────────────────────────────────────────────────── - // Course completion with certificate generation (#357, #358, #396) - // ────────────────────────────────────────────────────────────────── - - /** - * Complete a course for a user. All side-effects (XP award, certificate - * generation, on-chain minting, reward recording) are executed inside a - * transactional atomic operation. If ANY side-effect fails, every - * previously-successful mutation is rolled back — the user is never - * left in a partially-completed state. - * - * **#357**: A verifiable certificate is always generated on course - * completion. The certificate includes a shareable URL and a - * verification code that external parties can use to confirm - * authenticity. - */ - async completeCourse(id: string, userId: string) { - const course = await this.courseRepo.findOne({ where: { id } }); - if (!course) { - throw new NotFoundException(`Course with ID ${id} not found.`); - } - - const xpReward = course.xpReward || 50; - - const txResult = await this.transactionManager.runAtomic(async (tx) => { - // 1. Record XP reward (with rollback snapshot) - const xpResult = this.rewardsService.recordActivity(userId, new Date(), xpReward); - await tx.addOperation(async (): Promise => ({ - restore: () => { - this.logger.warn( - `[TxRollback] XP award of ${xpReward} for user=${userId} on course=${id} could not be reversed (global xpStore)`, - ); - }, - data: { userId, courseId: id, xpReward, recordedAt: new Date() }, - })); - - // 2. Generate verifiable certificate (#357) - const certificate = await this.certificateService.generateCertificate({ - userId, - courseId: id, - courseTitle: course.title, - xpAwarded: xpReward, - }); - - await tx.addOperation(async (): Promise => ({ - restore: () => { - this.certificateService.revokeCertificate(certificate.id); - }, - data: { certificateId: certificate.id }, - })); - - // 3. Mint certificate NFT via contract adapter (#396) - let onChainCertificate: - | { tokenId: string; transactionHash: string } - | undefined; - if (this.contractAdapter) { - try { - onChainCertificate = await this.contractAdapter.mintCertificate( - userId, - id, - { - courseTitle: course.title, - xpReward, - completedAt: new Date().toISOString(), - verificationCode: certificate.verificationCode, - }, - ); - - await tx.addOperation(async (): Promise => ({ - restore: () => { - this.logger.warn( - `[TxRollback] On-chain certificate mint for user=${userId}, course=${id} cannot be reversed`, - ); - }, - data: { onChainCertificate }, - })); - - // Record the on-chain reward as well - await this.contractAdapter.recordReward( - userId, - xpReward, - `Completed course: ${course.title}`, - ); - - await tx.addOperation(async (): Promise => ({ - restore: () => { - this.logger.warn( - `[TxRollback] On-chain reward for user=${userId}, course=${id} cannot be reversed`, - ); - }, - data: { userId, xpReward }, - })); - } catch (err) { - // #396: Contract failures should not block course completion. - // The user gets their XP reward and certificate regardless. - this.logger.warn( - `[CourseService] Contract adapter operation failed during course completion (non-blocking): ${err}`, - ); - } - } - - return { xpResult, certificate, onChainCertificate }; - }); - - if (!txResult.success) { - this.logger.error( - `Course completion transaction failed for user=${userId}, course=${id}: ${txResult.error?.message}`, - ); - throw txResult.error; - } - - return { - message: 'Course completed successfully', - courseId: id, - userId, - xpAwarded: xpReward, - progression: txResult.result!.xpResult, - certificate: { - id: txResult.result!.certificate.id, - verificationCode: txResult.result!.certificate.verificationCode, - shareableUrl: txResult.result!.certificate.shareableUrl, - issuedAt: txResult.result!.certificate.issuedAt, - ...(txResult.result!.onChainCertificate ?? {}), - }, - }; - } - - // ────────────────────────────────────────────────────────────────── - // Internals - // ────────────────────────────────────────────────────────────────── - - private async appendRevision( - course: CourseEntity, - reason: CourseRevisionReason, - options: { - changeNote?: string; - revisionAuthor?: string; - previousVersion?: number; - referenceRevisionId?: string; - } = {}, - ): Promise { - const revision = this.revisionRepo.create({ - id: crypto.randomUUID(), - courseId: course.id, - version: course.version, - snapshot: { - title: course.title, - description: course.description, - level: course.level, - order: course.order, - learningPathId: course.learningPathId, - duration: course.duration, - category: course.category, - categories: [...(course.categories ?? [])], - tags: [...(course.tags ?? [])], - prerequisites: [...(course.prerequisites ?? [])], - skills: [...(course.skills ?? [])], - xpReward: course.xpReward, - isActive: course.isActive, - }, - changeNote: options.changeNote, - revisionAuthor: options.revisionAuthor, - reason, - previousVersion: options.previousVersion, - referenceRevisionId: options.referenceRevisionId, - }); - const savedRevision = await this.revisionRepo.save(revision); - - course.latestRevisionId = savedRevision.id; - course.updatedAt = new Date(); - await this.courseRepo.save(course); - return savedRevision; - } - - private syncCourseTaxonomy( - course: CourseEntity, - dto: Pick, - ): void { - if (dto.category && !dto.categories) { - course.categories = [dto.category]; - } - if (dto.categories?.length && !dto.category) { - course.category = dto.categories[0]; - } - } - - /** - * BA-047: Canonicalize course taxonomy input so that only bounded, - * normalized values reach persistence. - * - * - Free-text fields (title, description, category) are trimmed and - * inner whitespace is collapsed. - * - The enum level is lower-cased to its canonical `CourseLevel` value. - * - Taxonomy arrays (categories, tags, prerequisites, skills) are - * trimmed, lower-cased, de-duplicated, and have blank items removed. - */ - private normalizeCourseInput>( - input: T, - ): Partial { - const normalized: Partial = { ...input } as Partial< - CourseEntity - >; - - if (typeof normalized.title === 'string') { - normalized.title = this.collapseWhitespace(normalized.title); - } - if (typeof normalized.description === 'string') { - normalized.description = normalized.description.trim(); - } - if (typeof normalized.category === 'string') { - normalized.category = this.normalizeTaxonomyItem(normalized.category); - } - if (typeof normalized.level === 'string') { - normalized.level = this.canonicalizeLevel(normalized.level); - } - - // Only normalize taxonomy arrays that were actually provided, so an - // absent field on update never overwrites already-persisted values with - // an empty/undefined array. - if (Array.isArray(input.categories)) { - normalized.categories = this.normalizeTaxonomy(input.categories); - } - if (Array.isArray(input.tags)) { - normalized.tags = this.normalizeTaxonomy(input.tags); - } - if (Array.isArray(input.prerequisites)) { - normalized.prerequisites = this.normalizeTaxonomy(input.prerequisites); - } - if (Array.isArray(input.skills)) { - normalized.skills = this.normalizeTaxonomy(input.skills); - } - - return normalized; - } - - private collapseWhitespace(value: string): string { - return value.trim().replace(/\s+/g, ' '); - } - - private normalizeTaxonomyItem(value: string): string { - return value - .trim() - .toLowerCase() - .replace(/\s+/g, ' '); - } - - private normalizeTaxonomy(values: string[]): string[] { - const seen = new Set(); - const result: string[] = []; - for (const raw of values) { - const item = this.normalizeTaxonomyItem(raw ?? ''); - if (item && !seen.has(item)) { - seen.add(item); - result.push(item); - } - } - return result; - } - - private canonicalizeLevel(level: string): CourseLevel { - const canonical = level.trim().toLowerCase(); - if ( - canonical === CourseLevel.BEGINNER || - canonical === CourseLevel.INTERMEDIATE || - canonical === CourseLevel.ADVANCED || - canonical === CourseLevel.WEB3 - ) { - return canonical as CourseLevel; - } - return level as CourseLevel; - } - - private async createUniqueSlug(title: string, excludeId?: string): Promise { - const baseSlug = this.normalizeSlug(title); - let slug = baseSlug; - let suffix = 2; - - while (true) { - const existing = await this.courseRepo.findOne({ where: { slug } }); - if (!existing || existing.id === excludeId) return slug; - slug = `${baseSlug}-${suffix}`; - suffix += 1; - } - } - - private normalizeSlug(title: string): string { - const normalized = title - .normalize('NFKD') - .replace(/[\u0300-\u036f]/g, '') - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, ''); - return normalized || 'course'; - } - - async getOrFail(id: string): Promise { - const course = await this.findById(id); - if (!course) { - throw new NotFoundException({ - error: 'COURSE_NOT_FOUND', - message: `Course with ID ${id} not found`, - }); - } - return course; - } - - // ────────────────────────────────────────────────────────────────── - // #356: Enrollment with concurrency protection - // ────────────────────────────────────────────────────────────────── - - /** - * Enrolls a user in a course with protection against concurrent - * duplicate subscriptions. Uses an in-flight lock to prevent - * race conditions when parallel requests arrive for the same - * userId + courseId pair. - * - * @throws ConflictException if the user is already enrolled or - * another enrollment request is in progress. - */ - async enrollUser( - userId: string, - courseId: string, - ): Promise { - const lockKey = `${userId}:${courseId}`; - - // Check if already enrolled - const existing = this.enrollments.get(lockKey); - if (existing && existing.status !== 'dropped') { - throw new ConflictException({ - error: 'ALREADY_ENROLLED', - message: `User ${userId} is already enrolled in course ${courseId}`, - }); - } - - // Acquire in-flight lock to prevent concurrent duplicate subscriptions - if (this.enrollmentLocks.has(lockKey)) { - throw new ConflictException({ - error: 'ENROLLMENT_IN_PROGRESS', - message: `An enrollment request for user ${userId} in course ${courseId} is already being processed`, - }); - } - - this.enrollmentLocks.add(lockKey); - - try { - // Re-check after acquiring lock (double-check pattern) - const recheck = this.enrollments.get(lockKey); - if (recheck && recheck.status !== 'dropped') { - throw new ConflictException({ - error: 'ALREADY_ENROLLED', - message: `User ${userId} is already enrolled in course ${courseId}`, - }); - } - - const enrollment: EnrollmentRecord = { - id: crypto.randomUUID(), - userId, - courseId, - enrolledAt: new Date(), - status: 'active', - completedAt: null, - progressPercent: 0, - }; - - this.enrollments.set(lockKey, enrollment); - return enrollment; - } finally { - this.enrollmentLocks.delete(lockKey); - } - } - - /** - * Returns enrollment status for a user in a specific course. - */ - getEnrollment( - userId: string, - courseId: string, - ): EnrollmentRecord | undefined { - return this.enrollments.get(`${userId}:${courseId}`); - } - - /** - * Returns all enrollments for a user across all courses. - */ - getUserEnrollments(userId: string): EnrollmentRecord[] { - return Array.from(this.enrollments.values()).filter( - (e) => e.userId === userId, - ); - } -} - -/** - * #356: Typed enrollment record for the concurrency-safe enrollment system. - */ -interface EnrollmentRecord { - id: string; - userId: string; - courseId: string; - enrolledAt: Date; - status: 'active' | 'completed' | 'dropped'; - completedAt: Date | null; - progressPercent: number; -} diff --git a/BackendAcademy/src/courses/dto/complete-course.dto.ts b/BackendAcademy/src/courses/dto/complete-course.dto.ts deleted file mode 100644 index 7fc62bedd2..0000000000 --- a/BackendAcademy/src/courses/dto/complete-course.dto.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { IsString, IsNotEmpty } from 'class-validator'; - -export class CompleteCourseDto { - @IsString() - @IsNotEmpty() - userId: string; -} diff --git a/BackendAcademy/src/courses/dto/course-dto.spec.ts b/BackendAcademy/src/courses/dto/course-dto.spec.ts deleted file mode 100644 index 7b60ef81c7..0000000000 --- a/BackendAcademy/src/courses/dto/course-dto.spec.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { validate } from 'class-validator'; -import { CreateCourseDto } from './create-course.dto'; -import { UpdateCourseDto } from './update-course.dto'; -import { CourseLevel } from '../interfaces/course-level.enum'; - -describe('CreateCourseDto validation (BA-047)', () => { - const valid = () => { - const dto = new CreateCourseDto(); - Object.assign(dto, { - title: 'Rust Basics', - description: 'An intro to Rust.', - level: CourseLevel.BEGINNER, - order: 1, - learningPathId: 'path-1', - duration: 60, - category: 'blockchain', - categories: ['blockchain'], - tags: ['ownership'], - prerequisites: ['borrowing'], - skills: ['memory-safety'], - }); - return dto; - }; - - it('accepts a valid payload', async () => { - expect(await validate(valid())).toEqual([]); - }); - - it('rejects a title that is too short', async () => { - const dto = valid(); - dto.title = 'Ru'; - const errors = await validate(dto); - expect(errors.some((e) => e.property === 'title')).toBe(true); - }); - - it('rejects an over-long title', async () => { - const dto = valid(); - dto.title = 'x'.repeat(121); - const errors = await validate(dto); - expect(errors.some((e) => e.property === 'title')).toBe(true); - }); - - it('rejects a blank required field', async () => { - const dto = valid(); - dto.description = ''; - const errors = await validate(dto); - expect(errors.some((e) => e.property === 'description')).toBe(true); - }); - - it('rejects an out-of-enum level', async () => { - const dto = valid(); - dto.level = 'extreme' as CourseLevel; - const errors = await validate(dto); - expect(errors.some((e) => e.property === 'level')).toBe(true); - }); - - it('rejects blank taxonomy items in skills', async () => { - const dto = valid(); - dto.skills = ['']; - const errors = await validate(dto); - expect(errors.some((e) => e.property === 'skills')).toBe(true); - }); - - it('rejects an over-long taxonomy item in tags', async () => { - const dto = valid(); - dto.tags = ['x'.repeat(61)]; - const errors = await validate(dto); - expect(errors.some((e) => e.property === 'tags')).toBe(true); - }); - - it('rejects taxonomy arrays that exceed the max item count', async () => { - const dto = valid(); - dto.skills = Array.from({ length: 21 }, (_, i) => `skill-${i}`); - const errors = await validate(dto); - expect(errors.some((e) => e.property === 'skills')).toBe(true); - }); -}); - -describe('UpdateCourseDto validation (BA-047)', () => { - it('accepts a valid partial payload', async () => { - const dto = new UpdateCourseDto(); - Object.assign(dto, { title: 'Rust Basics', skills: ['mem'] }); - expect(await validate(dto)).toEqual([]); - }); - - it('rejects a title that is too short on update', async () => { - const dto = new UpdateCourseDto(); - Object.assign(dto, { title: 'Ru' }); - const errors = await validate(dto); - expect(errors.some((e) => e.property === 'title')).toBe(true); - }); - - it('rejects an over-long taxonomy item in categories on update', async () => { - const dto = new UpdateCourseDto(); - Object.assign(dto, { categories: ['c'.repeat(61)] }); - const errors = await validate(dto); - expect(errors.some((e) => e.property === 'categories')).toBe(true); - }); -}); \ No newline at end of file diff --git a/BackendAcademy/src/courses/dto/create-course.dto.ts b/BackendAcademy/src/courses/dto/create-course.dto.ts deleted file mode 100644 index f80a465c76..0000000000 --- a/BackendAcademy/src/courses/dto/create-course.dto.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { - ArrayMaxSize, - IsArray, - IsEnum, - IsNotEmpty, - IsNumber, - IsOptional, - IsString, - MaxLength, - MinLength, -} from 'class-validator'; -import { CourseLevel } from '../interfaces/course-level.enum'; - -/** Shared bounds for canonical course taxonomy (see BA-047). */ -export const COURSE_TITLE_MIN_LENGTH = 3; -export const COURSE_TITLE_MAX_LENGTH = 120; -export const COURSE_DESCRIPTION_MAX_LENGTH = 4000; -export const COURSE_TAXONOMY_ITEM_MAX_LENGTH = 60; -export const COURSE_TAXONOMY_MAX_ITEMS = 20; - -export class CreateCourseDto { - @IsString() - @IsNotEmpty() - @MinLength(COURSE_TITLE_MIN_LENGTH) - @MaxLength(COURSE_TITLE_MAX_LENGTH) - title: string; - - @IsString() - @IsNotEmpty() - @MaxLength(COURSE_DESCRIPTION_MAX_LENGTH) - description: string; - - @IsEnum(CourseLevel) - level: CourseLevel; - - @IsNumber() - order: number; - - @IsString() - @IsNotEmpty() - @MaxLength(100) - learningPathId: string; - - @IsNumber() - duration: number; - - @IsOptional() - @IsString() - @IsNotEmpty() - @MaxLength(COURSE_TAXONOMY_ITEM_MAX_LENGTH) - category?: string; - - @IsOptional() - @IsArray() - @ArrayMaxSize(COURSE_TAXONOMY_MAX_ITEMS) - @IsString({ each: true }) - @IsNotEmpty({ each: true }) - @MinLength(1, { each: true }) - @MaxLength(COURSE_TAXONOMY_ITEM_MAX_LENGTH, { each: true }) - categories?: string[]; - - @IsOptional() - @IsArray() - @ArrayMaxSize(COURSE_TAXONOMY_MAX_ITEMS) - @IsString({ each: true }) - @IsNotEmpty({ each: true }) - @MinLength(1, { each: true }) - @MaxLength(COURSE_TAXONOMY_ITEM_MAX_LENGTH, { each: true }) - tags?: string[]; - - @IsOptional() - @IsArray() - @ArrayMaxSize(COURSE_TAXONOMY_MAX_ITEMS) - @IsString({ each: true }) - @IsNotEmpty({ each: true }) - @MinLength(1, { each: true }) - @MaxLength(COURSE_TAXONOMY_ITEM_MAX_LENGTH, { each: true }) - prerequisites?: string[]; - - @IsOptional() - @IsArray() - @ArrayMaxSize(COURSE_TAXONOMY_MAX_ITEMS) - @IsString({ each: true }) - @IsNotEmpty({ each: true }) - @MinLength(1, { each: true }) - @MaxLength(COURSE_TAXONOMY_ITEM_MAX_LENGTH, { each: true }) - skills?: string[]; - - @IsOptional() - @IsNumber() - xpReward?: number; -} \ No newline at end of file diff --git a/BackendAcademy/src/courses/dto/create-rating.dto.ts b/BackendAcademy/src/courses/dto/create-rating.dto.ts deleted file mode 100644 index d2c0b77b0b..0000000000 --- a/BackendAcademy/src/courses/dto/create-rating.dto.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { IsNumber, IsOptional, IsString, Max, Min, Length } from 'class-validator'; - -/** - * Request body for submitting or updating a course rating. - */ -export class CreateRatingDto { - /** - * Rating value from 1 to 5 (inclusive). - */ - @IsNumber() - @Min(1) - @Max(5) - rating: number; - - /** - * Optional review text, max 2000 characters. - */ - @IsOptional() - @IsString() - @Length(0, 2000) - review?: string; -} diff --git a/BackendAcademy/src/courses/dto/rating-stats.dto.ts b/BackendAcademy/src/courses/dto/rating-stats.dto.ts deleted file mode 100644 index a3daa14eee..0000000000 --- a/BackendAcademy/src/courses/dto/rating-stats.dto.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Distribution of ratings (1 to 5 stars). - */ -export class RatingDistributionDto { - oneStar: number; - twoStar: number; - threeStar: number; - fourStar: number; - fiveStar: number; -} - -/** - * Aggregated rating statistics for a course. - */ -export class CourseRatingStatsDto { - courseId: string; - averageRating: number; - totalRatings: number; - ratingDistribution: RatingDistributionDto; -} diff --git a/BackendAcademy/src/courses/dto/restore-revision.dto.ts b/BackendAcademy/src/courses/dto/restore-revision.dto.ts deleted file mode 100644 index 9ee1898f91..0000000000 --- a/BackendAcademy/src/courses/dto/restore-revision.dto.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { IsOptional, IsString, MaxLength } from 'class-validator'; - -export class RestoreRevisionDto { - @IsOptional() - @IsString() - @MaxLength(120) - revisionAuthor?: string; -} diff --git a/BackendAcademy/src/courses/dto/update-course.dto.ts b/BackendAcademy/src/courses/dto/update-course.dto.ts deleted file mode 100644 index 7027fe044d..0000000000 --- a/BackendAcademy/src/courses/dto/update-course.dto.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { - ArrayMaxSize, - IsArray, - IsBoolean, - IsEnum, - IsNotEmpty, - IsNumber, - IsOptional, - IsString, - MaxLength, - MinLength, -} from 'class-validator'; -import { CourseLevel } from '../interfaces/course-level.enum'; -import { - COURSE_DESCRIPTION_MAX_LENGTH, - COURSE_TAXONOMY_ITEM_MAX_LENGTH, - COURSE_TAXONOMY_MAX_ITEMS, - COURSE_TITLE_MAX_LENGTH, - COURSE_TITLE_MIN_LENGTH, -} from './create-course.dto'; - -export class UpdateCourseDto { - @IsOptional() - @IsString() - @IsNotEmpty() - @MinLength(COURSE_TITLE_MIN_LENGTH) - @MaxLength(COURSE_TITLE_MAX_LENGTH) - title?: string; - - @IsOptional() - @IsString() - @IsNotEmpty() - @MaxLength(COURSE_DESCRIPTION_MAX_LENGTH) - description?: string; - - @IsOptional() - @IsEnum(CourseLevel) - level?: CourseLevel; - - @IsOptional() - @IsNumber() - order?: number; - - @IsOptional() - @IsString() - @IsNotEmpty() - @MaxLength(100) - learningPathId?: string; - - @IsOptional() - @IsNumber() - duration?: number; - - @IsOptional() - @IsString() - @IsNotEmpty() - @MaxLength(COURSE_TAXONOMY_ITEM_MAX_LENGTH) - category?: string; - - @IsOptional() - @IsArray() - @ArrayMaxSize(COURSE_TAXONOMY_MAX_ITEMS) - @IsString({ each: true }) - @IsNotEmpty({ each: true }) - @MinLength(1, { each: true }) - @MaxLength(COURSE_TAXONOMY_ITEM_MAX_LENGTH, { each: true }) - categories?: string[]; - - @IsOptional() - @IsArray() - @ArrayMaxSize(COURSE_TAXONOMY_MAX_ITEMS) - @IsString({ each: true }) - @IsNotEmpty({ each: true }) - @MinLength(1, { each: true }) - @MaxLength(COURSE_TAXONOMY_ITEM_MAX_LENGTH, { each: true }) - tags?: string[]; - - @IsOptional() - @IsArray() - @ArrayMaxSize(COURSE_TAXONOMY_MAX_ITEMS) - @IsString({ each: true }) - @IsNotEmpty({ each: true }) - @MinLength(1, { each: true }) - @MaxLength(COURSE_TAXONOMY_ITEM_MAX_LENGTH, { each: true }) - prerequisites?: string[]; - - @IsOptional() - @IsArray() - @ArrayMaxSize(COURSE_TAXONOMY_MAX_ITEMS) - @IsString({ each: true }) - @IsNotEmpty({ each: true }) - @MinLength(1, { each: true }) - @MaxLength(COURSE_TAXONOMY_ITEM_MAX_LENGTH, { each: true }) - skills?: string[]; - - @IsOptional() - @IsNumber() - xpReward?: number; - - @IsOptional() - @IsBoolean() - isActive?: boolean; - - @IsOptional() - @IsString() - @MaxLength(2000) - changeNote?: string; - - @IsOptional() - @IsString() - @MaxLength(120) - revisionAuthor?: string; -} \ No newline at end of file diff --git a/BackendAcademy/src/courses/enrollment-eligibility.util.ts b/BackendAcademy/src/courses/enrollment-eligibility.util.ts deleted file mode 100644 index 6fda77fac7..0000000000 --- a/BackendAcademy/src/courses/enrollment-eligibility.util.ts +++ /dev/null @@ -1,22 +0,0 @@ -export type EligibilityCode = - | 'OK' - | 'ACCOUNT_INACTIVE' - | 'COURSE_UNAVAILABLE' - | 'PREREQUISITE_MISSING' - | 'PAYMENT_REQUIRED'; - -export interface EligibilityInput { - accountActive: boolean; - courseAvailable: boolean; - prerequisitesMet: boolean; - paymentSatisfied: boolean; -} - -/** Evaluates enrollment eligibility, returning the first failing stable code. */ -export function checkEnrollmentEligibility(input: EligibilityInput): EligibilityCode { - if (!input.accountActive) return 'ACCOUNT_INACTIVE'; - if (!input.courseAvailable) return 'COURSE_UNAVAILABLE'; - if (!input.prerequisitesMet) return 'PREREQUISITE_MISSING'; - if (!input.paymentSatisfied) return 'PAYMENT_REQUIRED'; - return 'OK'; -} diff --git a/BackendAcademy/src/courses/enrollment-record.util.ts b/BackendAcademy/src/courses/enrollment-record.util.ts deleted file mode 100644 index 7dcf58e58e..0000000000 --- a/BackendAcademy/src/courses/enrollment-record.util.ts +++ /dev/null @@ -1,28 +0,0 @@ -export interface EnrollmentRecord { - userId: string; - courseId: string; - enrolledAt: Date; -} - -/** Builds the durable, uniquely-keyed identifier for an enrollment row. */ -export function enrollmentKey(userId: string, courseId: string): string { - return `${userId}:${courseId}`; -} - -/** - * Idempotently inserts an enrollment into a durable store keyed by - * (userId, courseId); repeated calls return the existing record instead - * of creating duplicates. - */ -export function enrollIdempotent( - store: Map, - userId: string, - courseId: string, -): EnrollmentRecord { - const key = enrollmentKey(userId, courseId); - const existing = store.get(key); - if (existing) return existing; - const record: EnrollmentRecord = { userId, courseId, enrolledAt: new Date() }; - store.set(key, record); - return record; -} diff --git a/BackendAcademy/src/courses/index.ts b/BackendAcademy/src/courses/index.ts deleted file mode 100644 index c6070bab3d..0000000000 --- a/BackendAcademy/src/courses/index.ts +++ /dev/null @@ -1,28 +0,0 @@ -export { CourseModule } from './course.module'; -export { CourseService } from './course.service'; -export { CourseEntity } from './course.entity'; -export { - CourseRevisionEntity, - CourseRevisionReason, -} from './course-revision.entity'; -export { CourseLevel } from './interfaces/course-level.enum'; -export { ICourse, ILesson, ITask } from './interfaces/course.interface'; -export { CreateCourseDto } from './dto/create-course.dto'; -export { UpdateCourseDto } from './dto/update-course.dto'; -export { RestoreRevisionDto } from './dto/restore-revision.dto'; -export { ProgressModule } from './progress/progress.module'; -export { ProgressService } from './progress/progress.service'; -export { ProgressController } from './progress/progress.controller'; -export { - RegisterCourseProgressDto, -} from './progress/dto/register-course-progress.dto'; -export { - RecordLessonCompletionDto, - RecordTaskCompletionDto, -} from './progress/dto/record-completion.dto'; -export { - CourseProgressStatus, - ICourseSnapshot, - IOverallSnapshot, - IProgressSnapshot, -} from './progress/interfaces/progress-snapshot.interface'; diff --git a/BackendAcademy/src/courses/interfaces/course-level.enum.ts b/BackendAcademy/src/courses/interfaces/course-level.enum.ts deleted file mode 100644 index a652ddcd81..0000000000 --- a/BackendAcademy/src/courses/interfaces/course-level.enum.ts +++ /dev/null @@ -1,14 +0,0 @@ -export enum CourseLevel { - BEGINNER = 'beginner', - INTERMEDIATE = 'intermediate', - ADVANCED = 'advanced', - WEB3 = 'web3', -} - - - -export enum schedule { - EARLY = "early", - MID = "mid", - LATELY = "lately" -} diff --git a/BackendAcademy/src/courses/interfaces/course.interface.ts b/BackendAcademy/src/courses/interfaces/course.interface.ts deleted file mode 100644 index ce1d076708..0000000000 --- a/BackendAcademy/src/courses/interfaces/course.interface.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { CourseLevel } from './course-level.enum'; - -export interface ICourse { - id: string; - title: string; - description: string; - level: CourseLevel; - order: number; - learningPathId: string; - duration: number; - category: string; - categories: string[]; - tags: string[]; - prerequisites: string[]; - skills: string[]; - xpReward: number; - isActive: boolean; - createdAt: Date; - updatedAt: Date; -} - -export interface ILesson { - id: string; - courseId: string; - title: string; - content: string; - order: number; - duration: number; - xpReward: number; -} - -export interface ITask { - id: string; - lessonId: string; - title: string; - description: string; - difficulty: CourseLevel; - testCases: string[]; - expectedOutput: string; - xpReward: number; - passingScore: number; -} diff --git a/BackendAcademy/src/courses/progress/dto/record-completion.dto.ts b/BackendAcademy/src/courses/progress/dto/record-completion.dto.ts deleted file mode 100644 index 966973ef25..0000000000 --- a/BackendAcademy/src/courses/progress/dto/record-completion.dto.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { IsInt, IsOptional, IsString, Min } from 'class-validator'; - -export class RecordLessonCompletionDto { - @IsString() - lessonId: string; - - /** - * XP earned for this completion. Defaults to 0 if not provided. - */ - @IsOptional() - @IsInt() - @Min(0) - xpEarned?: number; -} - -export class RecordTaskCompletionDto { - @IsString() - taskId: string; - - /** - * XP earned for this completion. Defaults to 0 if not provided. - */ - @IsOptional() - @IsInt() - @Min(0) - xpEarned?: number; -} diff --git a/BackendAcademy/src/courses/progress/dto/register-course-progress.dto.ts b/BackendAcademy/src/courses/progress/dto/register-course-progress.dto.ts deleted file mode 100644 index c43541cbcd..0000000000 --- a/BackendAcademy/src/courses/progress/dto/register-course-progress.dto.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { IsInt, IsOptional, IsString, Min } from 'class-validator'; - -export class RegisterCourseProgressDto { - @IsString() - courseId: string; - - /** - * Total number of lessons the learner needs to complete for this course. - * Optional - pass an explicit integer (including 0) to set; omitting the - * key on a re-register call keeps the previously registered value. - */ - @IsOptional() - @IsInt() - @Min(0) - totalLessons?: number; - - /** - * Total number of tasks the learner needs to complete for this course. - */ - @IsOptional() - @IsInt() - @Min(0) - totalTasks?: number; -} diff --git a/BackendAcademy/src/courses/progress/interfaces/progress-snapshot.interface.ts b/BackendAcademy/src/courses/progress/interfaces/progress-snapshot.interface.ts deleted file mode 100644 index 28bd7eedd6..0000000000 --- a/BackendAcademy/src/courses/progress/interfaces/progress-snapshot.interface.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { CourseLevel } from '../../interfaces/course-level.enum'; - -/** - * Aggregated progress for a single course within a learner's snapshot. - * `totalLessons` / `totalTasks` are populated when the course is registered - * with the progress service; until then we fall back to the raw counts - * relative to what has been completed. - */ -export interface ICourseSnapshot { - courseId: string; - title: string; - level: CourseLevel; - learningPathId: string; - status: CourseProgressStatus; - completionPercent: number; - lessonsCompleted: number; - totalLessons: number; - tasksCompleted: number; - totalTasks: number; - xpEarned: number; - xpAvailable: number; - startedAt: Date; - lastActivityAt: Date | null; - completedAt: Date | null; - /** - * Hint for UI "continue where you left off" affordances. Returns the - * first lesson id the learner recorded as completed for this course. - * Uses JavaScript Set insertion order, so when learners complete - * lessons sequentially it matches the chronological first; - * intentionally NOT a lesson-index lookup. - */ - firstCompletedLessonId: string | null; -} - -export interface IOverallSnapshot { - totalXp: number; - coursesCompleted: number; - coursesInProgress: number; - lessonsCompleted: number; - tasksCompleted: number; - lastActiveAt: Date | null; -} - -export interface IProgressSnapshot { - userId: string; - generatedAt: Date; - overall: IOverallSnapshot; - courses: ICourseSnapshot[]; -} - -export enum CourseProgressStatus { - NOT_STARTED = 'not_started', - IN_PROGRESS = 'in_progress', - COMPLETED = 'completed', -} diff --git a/BackendAcademy/src/courses/progress/progress.controller.ts b/BackendAcademy/src/courses/progress/progress.controller.ts deleted file mode 100644 index e0acb7c387..0000000000 --- a/BackendAcademy/src/courses/progress/progress.controller.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { - Body, - Controller, - Delete, - Get, - HttpCode, - HttpStatus, - NotFoundException, - Param, - ParseUUIDPipe, - Post, - Put, -} from '@nestjs/common'; -import { RegisterCourseProgressDto } from './dto/register-course-progress.dto'; -import { - RecordLessonCompletionDto, - RecordTaskCompletionDto, -} from './dto/record-completion.dto'; -import { - ICourseSnapshot, - IProgressSnapshot, -} from './interfaces/progress-snapshot.interface'; -import { CourseProgressRecord, ProgressService } from './progress.service'; - -@Controller('courses/progress') -export class ProgressController { - constructor(private readonly progressService: ProgressService) {} - - /** - * GET /courses/progress/snapshot/:userId - * Returns the aggregated snapshot covering every course the learner has - * touched, sorted by most recently active. - */ - @Get('snapshot/:userId') - async getSnapshot( - @Param('userId', ParseUUIDPipe) userId: string, - ): Promise { - return this.progressService.getSnapshot(userId); - } - - /** - * GET /courses/progress/snapshot/:userId/course/:courseId - * Returns the snapshot scoped to a single course, or 404 when the learner - * has no progress row for the course yet. - */ - @Get('snapshot/:userId/course/:courseId') - async getCourseSnapshot( - @Param('userId', ParseUUIDPipe) userId: string, - @Param('courseId', ParseUUIDPipe) courseId: string, - ): Promise { - const snapshot = await this.progressService.getCourseSnapshot(userId, courseId); - if (!snapshot) { - throw new NotFoundException( - `No progress recorded for user ${userId} on course ${courseId}`, - ); - } - return snapshot; - } - - /** - * POST /courses/progress/:userId/courses - * Register a learner's enrollment in a course, optionally with the known - * lesson / task totals so completion percentages are meaningful. - */ - @Post(':userId/courses') - async registerCourse( - @Param('userId', ParseUUIDPipe) userId: string, - @Body() dto: RegisterCourseProgressDto, - ): Promise { - return this.progressService.registerCourse(userId, dto); - } - - /** - * PUT /courses/progress/:userId/courses/:courseId/lessons - * Record that the learner completed a lesson in the given course. - */ - @Put(':userId/courses/:courseId/lessons') - async completeLesson( - @Param('userId', ParseUUIDPipe) userId: string, - @Param('courseId', ParseUUIDPipe) courseId: string, - @Body() dto: RecordLessonCompletionDto, - ): Promise { - return this.progressService.recordLessonCompletion(userId, courseId, dto); - } - - /** - * PUT /courses/progress/:userId/courses/:courseId/tasks - * Record that the learner completed a task in the given course. - */ - @Put(':userId/courses/:courseId/tasks') - async completeTask( - @Param('userId', ParseUUIDPipe) userId: string, - @Param('courseId', ParseUUIDPipe) courseId: string, - @Body() dto: RecordTaskCompletionDto, - ) { - return this.progressService.recordTaskCompletion(userId, courseId, dto); - } - - /** - * DELETE /courses/progress/:userId - * Wipe the learner's snapshot. Primarily intended for tests and admin ops. - */ - @Delete(':userId') - @HttpCode(HttpStatus.NO_CONTENT) - async reset(@Param('userId', ParseUUIDPipe) userId: string): Promise { - await this.progressService.resetLearner(userId); - } -} \ No newline at end of file diff --git a/BackendAcademy/src/courses/progress/progress.module.ts b/BackendAcademy/src/courses/progress/progress.module.ts deleted file mode 100644 index 99c32ad925..0000000000 --- a/BackendAcademy/src/courses/progress/progress.module.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Module } from '@nestjs/common'; -import { CourseModule } from '../course.module'; -import { ProgressController } from './progress.controller'; -import { ProgressService } from './progress.service'; -import { TransactionManagerService } from '../../common/transaction-manager.service'; - -@Module({ - imports: [CourseModule], - controllers: [ProgressController], - providers: [ProgressService, TransactionManagerService], - exports: [ProgressService], -}) -export class ProgressModule {} diff --git a/BackendAcademy/src/courses/progress/progress.service.spec.ts b/BackendAcademy/src/courses/progress/progress.service.spec.ts deleted file mode 100644 index 2ac1025fc7..0000000000 --- a/BackendAcademy/src/courses/progress/progress.service.spec.ts +++ /dev/null @@ -1,493 +0,0 @@ -import { NotFoundException } from '@nestjs/common'; -import { CourseService } from '../course.service'; -import { CourseEntity } from '../course.entity'; -import { CourseRevisionEntity } from '../course-revision.entity'; -import { CourseLevel } from '../interfaces/course-level.enum'; -import { RegisterCourseProgressDto } from './dto/register-course-progress.dto'; -import { - RecordLessonCompletionDto, - RecordTaskCompletionDto, -} from './dto/record-completion.dto'; -import { RewardsService } from '../../rewards/rewards.service'; -import { TransactionManagerService } from '../../common/transaction-manager.service'; -import { - CourseProgressStatus, -} from './interfaces/progress-snapshot.interface'; -import { ProgressService } from './progress.service'; - -/** - * Minimal in-memory mock that imitates the subset of the - * `Repository` surface that `CourseService` relies on. - */ -class InMemoryRepository { - protected readonly rows: Map = new Map(); - constructor(protected readonly EntityCtor?: new (partial?: Partial) => T) {} - create(partial: Partial = {}): T { - if (this.EntityCtor) return new this.EntityCtor(partial); - return { ...(partial as T) }; - } - async save(entity: T): Promise { - if (!entity.id) (entity as T & { id: string }).id = crypto.randomUUID(); - const now = new Date(); - if ('createdAt' in entity && !(entity as { createdAt?: Date }).createdAt) (entity as { createdAt: Date }).createdAt = now; - if ('updatedAt' in entity) (entity as { updatedAt: Date }).updatedAt = now; - this.rows.set((entity as T & { id: string }).id, entity); - return entity; - } - async find(options: { where?: Partial } = {}): Promise { - const matches: T[] = []; - for (const row of this.rows.values()) { - const ok = Object.entries(options.where ?? {}).every(([k, v]) => (row as any)[k] === v); - if (ok) matches.push(row); - } - return matches; - } - async findOne(options: { where: Partial }): Promise { - for (const row of this.rows.values()) { - const ok = Object.entries(options.where).every(([k, v]) => (row as any)[k] === v); - if (ok) return row; - } - return null; - } - async remove(entity: T): Promise { - this.rows.delete((entity as T & { id: string }).id); - return entity; - } - async count(options: { where?: Partial } = {}): Promise { - return (await this.find(options)).length; - } -} - -class InMemoryCourseRepo extends InMemoryRepository { - constructor() { super(CourseEntity); } -} -class InMemoryRevisionRepo extends InMemoryRepository { - constructor() { super(CourseRevisionEntity); } -} - -/** - * Build a fresh CourseService and seed two known courses so tests can - * snapshot progress against stable ids. Course ids come from - * courseService.create() because CreateCourseDto doesn't accept an id. - */ -async function buildServices() { - const courseRepo = new InMemoryCourseRepo(); - const revisionRepo = new InMemoryRevisionRepo(); - const courseService = new CourseService( - courseRepo as unknown as import('typeorm').Repository, - revisionRepo as unknown as import('typeorm').Repository, - { recordActivity: jest.fn() } as unknown as RewardsService, - new TransactionManagerService(), - null as any, - ); - const service = new ProgressService(courseService, new TransactionManagerService()); - const x = await courseService.create({ - title: 'Rust Basics', - description: 'A starter course', - level: CourseLevel.BEGINNER, - order: 1, - learningPathId: 'path-rust-basics', - duration: 60, - xpReward: 200, - }); - const y = await courseService.create({ - title: 'Rust Web3', - description: 'Web3 specialization', - level: CourseLevel.WEB3, - order: 2, - learningPathId: 'path-rust-web3', - duration: 90, - xpReward: 400, - }); - return { service, courseService, courseX: x.id, courseY: y.id }; -} - -const USER_A = '00000000-0000-4000-8000-0000000000aa'; -const USER_B = '00000000-0000-4000-8000-0000000000bb'; - -describe('ProgressService', () => { - let service: ProgressService; - let courseService: CourseService; - let courseX: string; - let courseY: string; - - beforeEach(async () => { - ({ service, courseService, courseX, courseY } = await buildServices()); - }); - - it('returns an empty snapshot for an unknown learner', async () => { - const snapshot = await service.getSnapshot(USER_A); - - expect(snapshot.userId).toBe(USER_A); - expect(snapshot.courses).toEqual([]); - expect(snapshot.overall).toEqual({ - totalXp: 0, - coursesCompleted: 0, - coursesInProgress: 0, - lessonsCompleted: 0, - tasksCompleted: 0, - lastActiveAt: null, - }); - expect(snapshot.generatedAt).toBeInstanceOf(Date); - }); - - it('registerCourse() throws NotFoundException when the course does not exist', async () => { - const dto: RegisterCourseProgressDto = { - courseId: 'not-a-real-course', - totalLessons: 5, - }; - - await expect(service.registerCourse(USER_A, dto)).rejects.toThrow( - NotFoundException, - ); - }); - - it('registerCourse() stores totals and seeds a placeholder progress row', async () => { - const record = await service.registerCourse(USER_A, { - courseId: courseX, - totalLessons: 4, - totalTasks: 2, - }); - - expect(record.courseId).toBe(courseX); - expect(record.totalLessons).toBe(4); - expect(record.totalTasks).toBe(2); - expect(record.completedLessonIds.size).toBe(0); - expect(record.completedAt).toBeNull(); - }); - - it('registerCourse() preserves completions on re-registration; explicit values overwrite', async () => { - await service.registerCourse(USER_A, { courseId: courseX, totalLessons: 4 }); - await service.recordLessonCompletion(USER_A, courseX, { - lessonId: 'lesson-1', - xpEarned: 20, - }); - - // An explicit totalTasks=3 on a second call fills the previously-empty - // tasks total; an explicit totalLessons=0 is a *valid* value meaning - // "no lessons required" and overwrites the prior 4. The critical - // guarantee is that prior completions survive the re-register. - const updated = await service.registerCourse(USER_A, { - courseId: courseX, - totalLessons: 0, - totalTasks: 3, - }); - - expect(updated.totalLessons).toBe(0); - expect(updated.totalTasks).toBe(3); - expect(updated.completedLessonIds.has('lesson-1')).toBe(true); - }); - - it('omitting a total on re-register keeps the previously registered value', async () => { - await service.registerCourse(USER_A, { - courseId: courseX, - totalLessons: 4, - totalTasks: 2, - }); - - // Total tasks is omitted -> prior value (2) is kept. Total lessons is - // undefined -> prior value (4) is kept. - const updated = await service.registerCourse(USER_A, { - courseId: courseX, - }); - - expect(updated.totalLessons).toBe(4); - expect(updated.totalTasks).toBe(2); - }); - - it('recordLessonCompletion() is idempotent for repeated lesson ids', async () => { - await service.registerCourse(USER_A, { courseId: courseX, totalLessons: 3 }); - const dto: RecordLessonCompletionDto = { lessonId: 'lesson-1', xpEarned: 50 }; - - await service.recordLessonCompletion(USER_A, courseX, dto); - const second = await service.recordLessonCompletion(USER_A, courseX, dto); - - expect(second.completedLessonIds.size).toBe(1); - expect(second.xpEarned).toBe(50); - }); - - it('recordTaskCompletion() accumulates XP per distinct task id', async () => { - await service.registerCourse(USER_A, { - courseId: courseX, - totalLessons: 2, - totalTasks: 3, - }); - - await service.recordTaskCompletion(USER_A, courseX, { - taskId: 'task-1', - xpEarned: 10, - }); - await service.recordTaskCompletion(USER_A, courseX, { - taskId: 'task-2', - xpEarned: 15, - }); - await service.recordTaskCompletion(USER_A, courseX, { - taskId: 'task-1', - xpEarned: 10, - }); - - const snapshot = await service.getCourseSnapshot(USER_A, courseX); - expect(snapshot?.xpEarned).toBe(25); - expect(snapshot?.tasksCompleted).toBe(2); - }); - - it('auto-registers a course when completion is recorded before registerCourse', async () => { - const record = await service.recordLessonCompletion(USER_A, courseX, { - lessonId: 'lesson-1', - xpEarned: 5, - }); - - expect(record.courseId).toBe(courseX); - expect(record.totalLessons).toBe(0); - expect(record.completedLessonIds.has('lesson-1')).toBe(true); - }); - - it('throws when recording completion for an unknown course', async () => { - await expect( - service.recordLessonCompletion(USER_A, 'bogus-course-id', { - lessonId: 'l1', - }), - ).rejects.toThrow(NotFoundException); - }); - - it('marks a course as COMPLETED only when lesson and task targets are both met', async () => { - await service.registerCourse(USER_A, { - courseId: courseX, - totalLessons: 2, - totalTasks: 1, - }); - - await service.recordLessonCompletion(USER_A, courseX, { - lessonId: 'l1', - xpEarned: 0, - }); - await service.recordLessonCompletion(USER_A, courseX, { - lessonId: 'l2', - xpEarned: 0, - }); - let snapshot = await service.getCourseSnapshot(USER_A, courseX); - expect(snapshot?.status).toBe(CourseProgressStatus.IN_PROGRESS); - - await service.recordTaskCompletion(USER_A, courseX, { - taskId: 't1', - xpEarned: 0, - }); - snapshot = await service.getCourseSnapshot(USER_A, courseX); - expect(snapshot?.status).toBe(CourseProgressStatus.COMPLETED); - expect(snapshot?.completedAt).toBeInstanceOf(Date); - }); - - it('returns IN_PROGRESS for a partially completed course', async () => { - await service.registerCourse(USER_A, { courseId: courseX, totalLessons: 5 }); - await service.recordLessonCompletion(USER_A, courseX, { - lessonId: 'l1', - xpEarned: 10, - }); - - const snapshot = await service.getCourseSnapshot(USER_A, courseX); - expect(snapshot?.status).toBe(CourseProgressStatus.IN_PROGRESS); - // (1/5) * 0.7 weighted + 0 tasks = 0.14 / 0.7 = 0.2 -> 20% - expect(snapshot?.completionPercent).toBe(20); - }); - - it('stays NOT_STARTED when no lessons/tasks have been recorded', async () => { - await service.registerCourse(USER_A, { - courseId: courseX, - totalLessons: 5, - totalTasks: 3, - }); - - const snapshot = await service.getCourseSnapshot(USER_A, courseX); - expect(snapshot?.status).toBe(CourseProgressStatus.NOT_STARTED); - }); - - it('emits a 0% completion when no totals are registered', async () => { - await service.recordLessonCompletion(USER_A, courseX, { lessonId: 'l1' }); - - const snapshot = await service.getCourseSnapshot(USER_A, courseX); - expect(snapshot?.completionPercent).toBe(0); - }); - - it('aggregates overall stats across multiple courses', async () => { - // Course X: complete everything - await service.registerCourse(USER_A, { - courseId: courseX, - totalLessons: 1, - totalTasks: 1, - }); - await service.recordLessonCompletion(USER_A, courseX, { - lessonId: 'l1', - xpEarned: 25, - }); - await service.recordTaskCompletion(USER_A, courseX, { - taskId: 't1', - xpEarned: 25, - }); - - // Course Y: partial - await service.registerCourse(USER_A, { courseId: courseY, totalLessons: 4 }); - await service.recordLessonCompletion(USER_A, courseY, { - lessonId: 'l1', - xpEarned: 10, - }); - - const snapshot = await service.getSnapshot(USER_A); - - expect(snapshot.overall.totalXp).toBe(60); - expect(snapshot.overall.coursesCompleted).toBe(1); - expect(snapshot.overall.coursesInProgress).toBe(1); - expect(snapshot.overall.lessonsCompleted).toBe(2); - expect(snapshot.overall.tasksCompleted).toBe(1); - expect(snapshot.overall.lastActiveAt).toBeInstanceOf(Date); - - expect(snapshot.courses).toHaveLength(2); - }); - - it('includes soft-deleted courses in the snapshot since they still have progress data', async () => { - await service.registerCourse(USER_A, { courseId: courseX, totalLessons: 1 }); - await courseService.remove(courseX); - - const snapshot = await service.getSnapshot(USER_A); - // With soft-delete, the course is inactive but still in the DB, - // so its progress data is preserved in the snapshot. - expect(snapshot.courses).toHaveLength(1); - expect(snapshot.courses[0].courseId).toBe(courseX); - }); - - it('getCourseSnapshot() returns null for a course the learner never touched', async () => { - expect(await service.getCourseSnapshot(USER_A, courseX)).toBeNull(); - }); - - it('isolates progress between learners', async () => { - await service.registerCourse(USER_A, { courseId: courseX, totalLessons: 1 }); - await service.recordLessonCompletion(USER_A, courseX, { - lessonId: 'l1', - xpEarned: 5, - }); - - const a = await service.getSnapshot(USER_A); - const b = await service.getSnapshot(USER_B); - - expect(a.overall.totalXp).toBe(5); - expect(a.courses).toHaveLength(1); - expect(b.courses).toHaveLength(0); - expect(b.overall.totalXp).toBe(0); - }); - - it('resetLearner() clears the learner state', async () => { - await service.registerCourse(USER_A, { courseId: courseX }); - await service.recordLessonCompletion(USER_A, courseX, { lessonId: 'l1' }); - - expect(await service.resetLearner(USER_A)).toBe(true); - const snapshot = await service.getSnapshot(USER_A); - expect(snapshot.courses).toEqual([]); - }); - - it('exposes the expected xpAvailable from course.xpReward', async () => { - await service.registerCourse(USER_A, { courseId: courseX }); - const snap = await service.getCourseSnapshot(USER_A, courseX); - expect(snap?.xpAvailable).toBe(200); - }); - - it('pushes untouched courses to the end of the snapshot', async () => { - // Register an untouched course first. - await service.registerCourse(USER_A, { courseId: courseY, totalLessons: 5 }); - - await new Promise((resolve) => setTimeout(resolve, 5)); - // Then register and touch a second course. - await service.registerCourse(USER_A, { courseId: courseX, totalLessons: 5 }); - await service.recordLessonCompletion(USER_A, courseX, { lessonId: 'l-x' }); - - const snapshot = await service.getSnapshot(USER_A); - const orderedIds = snapshot.courses.map((c) => c.courseId); - - // courseX was touched (first), courseY was not touched (last). - expect(orderedIds[0]).toBe(courseX); - expect(orderedIds[orderedIds.length - 1]).toBe(courseY); - }); - - // --------------------------------------------------------------------------- - // #458: Monotonic progress — progress never regresses - // --------------------------------------------------------------------------- - - it('progress is monotonic: completedAt never regresses once set', async () => { - await service.registerCourse(USER_A, { - courseId: courseX, - totalLessons: 1, - totalTasks: 1, - }); - - // Complete all requirements - await service.recordLessonCompletion(USER_A, courseX, { - lessonId: 'l1', xpEarned: 10, - }); - await service.recordTaskCompletion(USER_A, courseX, { - taskId: 't1', xpEarned: 10, - }); - - const completedSnapshot = await service.getCourseSnapshot(USER_A, courseX); - const completedAt = completedSnapshot!.completedAt; - expect(completedAt).toBeInstanceOf(Date); - - // Re-recording a lesson should not un-complete the course - await service.recordLessonCompletion(USER_A, courseX, { - lessonId: 'l1', xpEarned: 10, - }); - - const afterRetry = await service.getCourseSnapshot(USER_A, courseX); - expect(afterRetry!.completedAt).toBeInstanceOf(Date); - // completedAt should not have regressed - expect(afterRetry!.completedAt!.getTime()).toBeGreaterThanOrEqual( - completedAt!.getTime(), - ); - }); - - it('lesson completions are idempotent: repeating the same lesson does not double-count', async () => { - await service.registerCourse(USER_A, { courseId: courseX, totalLessons: 2 }); - - await service.recordLessonCompletion(USER_A, courseX, { - lessonId: 'l1', xpEarned: 20, - }); - await service.recordLessonCompletion(USER_A, courseX, { - lessonId: 'l1', xpEarned: 20, - }); - await service.recordLessonCompletion(USER_A, courseX, { - lessonId: 'l1', xpEarned: 20, - }); - - const snap = await service.getCourseSnapshot(USER_A, courseX); - expect(snap!.lessonsCompleted).toBe(1); - expect(snap!.xpEarned).toBe(20); - }); - - it('task completions are idempotent: repeating the same task does not double-count', async () => { - await service.registerCourse(USER_A, { courseId: courseX, totalTasks: 2 }); - - await service.recordTaskCompletion(USER_A, courseX, { - taskId: 't1', xpEarned: 15, - }); - await service.recordTaskCompletion(USER_A, courseX, { - taskId: 't1', xpEarned: 15, - }); - - const snap = await service.getCourseSnapshot(USER_A, courseX); - expect(snap!.tasksCompleted).toBe(1); - expect(snap!.xpEarned).toBe(15); - }); - - it('exposes firstCompletedLessonId hint from prior lesson completions', async () => { - await service.registerCourse(USER_A, { courseId: courseX }); - await service.recordLessonCompletion(USER_A, courseX, { - lessonId: 'l-first', - }); - - const snap = await service.getCourseSnapshot(USER_A, courseX); - expect(snap?.firstCompletedLessonId).toBe('l-first'); - - // When no lessons are completed the hint is null. - await service.resetLearner(USER_A); - await service.registerCourse(USER_A, { courseId: courseX }); - const fresh = await service.getCourseSnapshot(USER_A, courseX); - expect(fresh?.firstCompletedLessonId).toBeNull(); - }); -}); \ No newline at end of file diff --git a/BackendAcademy/src/courses/progress/progress.service.ts b/BackendAcademy/src/courses/progress/progress.service.ts deleted file mode 100644 index b15db0ca84..0000000000 --- a/BackendAcademy/src/courses/progress/progress.service.ts +++ /dev/null @@ -1,483 +0,0 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { CourseEntity } from '../course.entity'; -import { CourseService } from '../course.service'; -import { - TransactionManagerService, - TransactionSnapshot, -} from '../../common/transaction-manager.service'; -import { RegisterCourseProgressDto } from './dto/register-course-progress.dto'; -import { - RecordLessonCompletionDto, - RecordTaskCompletionDto, -} from './dto/record-completion.dto'; -import { - CourseProgressStatus, - ICourseSnapshot, - IOverallSnapshot, - IProgressSnapshot, -} from './interfaces/progress-snapshot.interface'; - -/** - * Weight applied to lesson completion and task completion when deriving - * the `completionPercent` field on the snapshot. Lessons are weighted - * higher because they represent the primary learning surface; tasks are - * treated as supplementary assessments. - */ -const LESSON_COMPLETION_WEIGHT = 0.7; -const TASK_COMPLETION_WEIGHT = 0.3; - -export interface CourseProgressRecord { - courseId: string; - totalLessons: number; - totalTasks: number; - completedLessonIds: Set; - completedTaskIds: Set; - xpEarned: number; - startedAt: Date; - lastActivityAt: Date | null; - completedAt: Date | null; -} - -interface LearnerProgressRecord { - userId: string; - courses: Map; -} - -/** - * Snapshot used to roll back a single progress mutation. Captures the - * minimal state needed to undo a lesson/task completion record. - */ -interface ProgressSnapshot extends TransactionSnapshot { - data: { - userId: string; - courseId: string; - lessonIdsAdded: string[]; - taskIdsAdded: string[]; - xpAdded: number; - previousCompletedAt: Date | null; - }; -} - -/** - * In-memory store for learner progress snapshots, scoped to the courses - * module. Uses CourseService to hydrate course metadata (title, level, - * learningPathId, xpReward) on the snapshot. The service is intentionally - * self-contained so the courses feature remains easy to evolve in isolation. - * - * All progress mutations (recordLessonCompletion, recordTaskCompletion) are - * wrapped in transactional atomic operations via {@link TransactionManagerService}. - * If any part of the update fails, the progress snapshot is rolled back to - * the previous consistent state — no partial progress is ever visible to - * callers. - */ -@Injectable() -export class ProgressService { - private readonly logger = new Logger(ProgressService.name); - private readonly learners: Map = new Map(); - - constructor( - private readonly courseService: CourseService, - private readonly transactionManager: TransactionManagerService, - ) {} - - /** - * Register that a learner is enrolled in a course and (optionally) lock - * in the totals used to compute completion percentages. - * - * Behaviour for re-registration: - * - `startedAt` is preserved. - * - `totalLessons` / `totalTasks` are only updated when the call site - * supplies an explicit value (including `0`). Omitting a field keeps - * the previously registered value. - * - Previously recorded lesson/task completions are NEVER cleared. - */ - async registerCourse( - userId: string, - dto: RegisterCourseProgressDto, - ): Promise { - const course = await this.courseService.findById(dto.courseId); - if (!course) { - throw new NotFoundException(`Course ${dto.courseId} not found`); - } - - const learner = this.ensureLearner(userId); - const existing = learner.courses.get(dto.courseId); - - if (existing) { - if (dto.totalLessons !== undefined) existing.totalLessons = dto.totalLessons; - if (dto.totalTasks !== undefined) existing.totalTasks = dto.totalTasks; - return existing; - } - - const record: CourseProgressRecord = { - courseId: dto.courseId, - totalLessons: dto.totalLessons ?? 0, - totalTasks: dto.totalTasks ?? 0, - completedLessonIds: new Set(), - completedTaskIds: new Set(), - xpEarned: 0, - startedAt: new Date(), - lastActivityAt: null, - completedAt: null, - }; - - learner.courses.set(dto.courseId, record); - return record; - } - - /** - * Record a lesson completion for (user, course). Repeating the same - * lesson id is a no-op for counters/xp but always refreshes the - * lastActivityAt timestamp. - * - * #458: Progress is monotonic — once a lesson is marked completed it - * can never be removed, and once the course is marked completed - * (`completedAt` is set) it can never regress to incomplete. - * - * All state mutations are wrapped in a transactional atomic operation. - * If any step fails the progress snapshot is restored to its prior - * consistent state so callers never observe partially-applied progress. - */ - async recordLessonCompletion( - userId: string, - courseId: string, - dto: RecordLessonCompletionDto, - ): Promise { - const result = await this.transactionManager.runAtomic(async (tx) => { - const record = await this.ensureCourseRecord(userId, courseId); - - // Capture pre-mutation state for potential rollback - const prevCompletedAt = record.completedAt; - const lessonIdsAdded: string[] = []; - let xpAdded = 0; - - const xp = dto.xpEarned ?? 0; - if (!record.completedLessonIds.has(dto.lessonId)) { - record.completedLessonIds.add(dto.lessonId); - record.xpEarned += xp; - lessonIdsAdded.push(dto.lessonId); - xpAdded = xp; - } - record.lastActivityAt = new Date(); - this.maybeMarkCompleted(record); - - // #458: Progress is monotonic — never regress completedAt - if (prevCompletedAt && !record.completedAt) { - record.completedAt = prevCompletedAt; - } - - // Register rollback snapshot - await tx.addOperation(async (): Promise => ({ - restore: () => { - for (const id of lessonIdsAdded) { - record.completedLessonIds.delete(id); - } - record.xpEarned -= xpAdded; - record.completedAt = prevCompletedAt; - }, - data: { userId, courseId, lessonIdsAdded, taskIdsAdded: [], xpAdded, previousCompletedAt: prevCompletedAt }, - })); - - return record; - }); - - if (!result.success) { - this.logger.error( - `Failed to record lesson completion for user=${userId}, course=${ courseId}, lesson=${dto.lessonId}: ${result.error?.message}`, - ); - throw result.error; - } - - return result.result!; - } - - /** - * Record a task completion for (user, course). - * - * #458: Progress is monotonic — once a task is marked completed it - * can never be removed, and once the course is marked completed - * (`completedAt` is set) it can never regress to incomplete. - * - * All state mutations are wrapped in a transactional atomic operation. - * If any step fails the progress snapshot is restored to its prior - * consistent state so callers never observe partially-applied progress. - */ - async recordTaskCompletion( - userId: string, - courseId: string, - dto: RecordTaskCompletionDto, - ): Promise { - const result = await this.transactionManager.runAtomic(async (tx) => { - const record = await this.ensureCourseRecord(userId, courseId); - - // Capture pre-mutation state for potential rollback - const prevCompletedAt = record.completedAt; - const taskIdsAdded: string[] = []; - let xpAdded = 0; - - const xp = dto.xpEarned ?? 0; - if (!record.completedTaskIds.has(dto.taskId)) { - record.completedTaskIds.add(dto.taskId); - record.xpEarned += xp; - taskIdsAdded.push(dto.taskId); - xpAdded = xp; - } - record.lastActivityAt = new Date(); - this.maybeMarkCompleted(record); - - // #458: Progress is monotonic — never regress completedAt - if (prevCompletedAt && !record.completedAt) { - record.completedAt = prevCompletedAt; - } - - // Register rollback snapshot - await tx.addOperation(async (): Promise => ({ - restore: () => { - for (const id of taskIdsAdded) { - record.completedTaskIds.delete(id); - } - record.xpEarned -= xpAdded; - record.completedAt = prevCompletedAt; - }, - data: { userId, courseId, lessonIdsAdded: [], taskIdsAdded, xpAdded, previousCompletedAt: prevCompletedAt }, - })); - - return record; - }); - - if (!result.success) { - this.logger.error( - `Failed to record task completion for user=${userId}, course=${courseId}, task=${dto.taskId}: ${result.error?.message}`, - ); - throw result.error; - } - - return result.result!; - } - - /** - * Compute the full snapshot for a learner, aggregating per-course state - * into overall totals and resolving course metadata via CourseService. - */ - async getSnapshot(userId: string): Promise { - const learner = this.learners.get(userId); - - const overall: IOverallSnapshot = { - totalXp: 0, - coursesCompleted: 0, - coursesInProgress: 0, - lessonsCompleted: 0, - tasksCompleted: 0, - lastActiveAt: null, - }; - - if (!learner) { - return { - userId, - generatedAt: new Date(), - overall, - courses: [], - }; - } - - const courses: ICourseSnapshot[] = []; - let latestActivity: Date | null = null; - - for (const record of learner.courses.values()) { - const course = await this.courseService.findById(record.courseId); - if (!course) { - continue; - } - - const status = this.computeStatus(record); - const completionPercent = this.computeCompletionPercent(record); - - if (status === CourseProgressStatus.COMPLETED) { - overall.coursesCompleted += 1; - } else if ( - record.completedLessonIds.size > 0 || - record.completedTaskIds.size > 0 - ) { - overall.coursesInProgress += 1; - } - - overall.totalXp += record.xpEarned; - overall.lessonsCompleted += record.completedLessonIds.size; - overall.tasksCompleted += record.completedTaskIds.size; - - if (record.lastActivityAt) { - if (!latestActivity || record.lastActivityAt > latestActivity) { - latestActivity = record.lastActivityAt; - } - } - - courses.push(this.buildCourseSnapshot(record, course, status, completionPercent)); - } - - overall.lastActiveAt = latestActivity; - courses.sort((a, b) => { - // Push untouched (NOT_STARTED) courses to the end so consumers see - // active work first. Among touched courses, sort by most recent - // activity; ties fall back to a stable id-based comparator. - const aUntouched = a.lastActivityAt == null; - const bUntouched = b.lastActivityAt == null; - if (aUntouched !== bUntouched) { - return aUntouched ? 1 : -1; - } - const aTs = a.lastActivityAt?.getTime() ?? 0; - const bTs = b.lastActivityAt?.getTime() ?? 0; - if (bTs !== aTs) return bTs - aTs; - return a.courseId.localeCompare(b.courseId); - }); - - return { - userId, - generatedAt: new Date(), - overall, - courses, - }; - } - - /** - * Compute the snapshot for a single (user, course) pair. Returns null when - * the learner has no progress row for that course so callers can distinguish - * "no progress yet" from "progress exists". - */ - async getCourseSnapshot( - userId: string, - courseId: string, - ): Promise { - const learner = this.learners.get(userId); - const record = learner?.courses.get(courseId); - if (!record) return null; - - const course = await this.courseService.findById(courseId); - if (!course) return null; - - const status = this.computeStatus(record); - const completionPercent = this.computeCompletionPercent(record); - return this.buildCourseSnapshot(record, course, status, completionPercent); - } - - /** - * Remove all progress for a learner. Useful for tests and admin tooling. - */ - async resetLearner(userId: string): Promise { - return this.learners.delete(userId); - } - - // ------------------ internals ------------------ - - private ensureLearner(userId: string): LearnerProgressRecord { - let learner = this.learners.get(userId); - if (!learner) { - learner = { userId, courses: new Map() }; - this.learners.set(userId, learner); - } - return learner; - } - - private async ensureCourseRecord( - userId: string, - courseId: string, - ): Promise { - const learner = this.ensureLearner(userId); - const existing = learner.courses.get(courseId); - if (existing) return existing; - - // Auto-register so completion recordings never require a separate - // register call. Totals stay at 0 until the caller provides them. - return this.registerCourse(userId, { courseId }); - } - - private maybeMarkCompleted(record: CourseProgressRecord): void { - if (record.totalLessons === 0 && record.totalTasks === 0) { - // No totals registered - we can't know if it's "completed". - record.completedAt = null; - return; - } - - const lessonsTargetMet = - record.totalLessons === 0 || - record.completedLessonIds.size >= record.totalLessons; - const tasksTargetMet = - record.totalTasks === 0 || - record.completedTaskIds.size >= record.totalTasks; - - if (lessonsTargetMet && tasksTargetMet && !record.completedAt) { - record.completedAt = new Date(); - } else if (!lessonsTargetMet || !tasksTargetMet) { - record.completedAt = null; - } - } - - private computeStatus(record: CourseProgressRecord): CourseProgressStatus { - if (record.completedAt) return CourseProgressStatus.COMPLETED; - if ( - record.completedLessonIds.size > 0 || - record.completedTaskIds.size > 0 - ) { - return CourseProgressStatus.IN_PROGRESS; - } - return CourseProgressStatus.NOT_STARTED; - } - - private computeCompletionPercent(record: CourseProgressRecord): number { - const weightLesson = record.totalLessons > 0 ? LESSON_COMPLETION_WEIGHT : 0; - const weightTask = record.totalTasks > 0 ? TASK_COMPLETION_WEIGHT : 0; - const totalWeight = weightLesson + weightTask; - - // If neither weight is known we report 0% rather than dividing by zero. - if (totalWeight === 0) return 0; - - const lessonPart = - record.totalLessons > 0 - ? record.completedLessonIds.size / record.totalLessons - : 0; - const taskPart = - record.totalTasks > 0 - ? record.completedTaskIds.size / record.totalTasks - : 0; - - const ratio = (lessonPart * weightLesson + taskPart * weightTask) / totalWeight; - return Math.max(0, Math.min(100, Math.round(ratio * 100))); - } - - private buildCourseSnapshot( - record: CourseProgressRecord, - course: CourseEntity, - status: CourseProgressStatus, - completionPercent: number, - ): ICourseSnapshot { - return { - courseId: record.courseId, - title: course.title, - level: course.level, - learningPathId: course.learningPathId, - status, - completionPercent, - lessonsCompleted: record.completedLessonIds.size, - totalLessons: record.totalLessons, - tasksCompleted: record.completedTaskIds.size, - totalTasks: record.totalTasks, - xpEarned: record.xpEarned, - xpAvailable: course.xpReward, - startedAt: record.startedAt, - lastActivityAt: record.lastActivityAt, - completedAt: record.completedAt, - firstCompletedLessonId: this.findFirstCompletedLessonId(record), - }; - } - - /** - * Returns the first lesson the learner recorded as completed for the - * given course. JavaScript Set iteration order is insertion order, so - * when learners record completions sequentially the result matches the - * chronological first; this is NOT a lesson-index lookup. - */ - private findFirstCompletedLessonId(record: CourseProgressRecord): string | null { - if (record.completedLessonIds.size === 0) return null; - const [first] = record.completedLessonIds; - return first ?? null; - } -} diff --git a/BackendAcademy/src/database/database.module.spec.ts b/BackendAcademy/src/database/database.module.spec.ts deleted file mode 100644 index 6bf149c6e8..0000000000 --- a/BackendAcademy/src/database/database.module.spec.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { shouldSynchronizeScchema } from './database.module'; - -describe('database schema synchronization', () => { - it('allows synchronization only for local development and tests', () => { - expect(shouldSynchronizeSchema('development')).toBe(true); - expect(shouldSynchronizeSchema('test')).toBe(true); - expect(shouldSynchronizeScchema('staging')).toBe(false); - expect(shouldSynchronizeSchema('production')).toBe(false); - }); - - it('disables synchronization for any non-local environment', () => { - expect(shouldSynchronizeSchema('qa')).toBe(false); - expect(shouldSynchronizeSchema('preview')).toBe(false); - expect(shouldSynchronizeSchema('preprod')).toBe(false); - }); - - it('defaults to development when NODE_ENV is missing or empty', () => { - expect(shouldSynchronizeSchema(undefined)).toBe(true); - expect(shouldSynchronizeSchema('')).toBe(true); - }); -}); diff --git a/BackendAcademy/src/database/database.module.ts b/BackendAcademy/src/database/database.module.ts deleted file mode 100644 index 1cbe655c4b..0000000000 --- a/BackendAcademy/src/database/database.module.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Module, Global } from '@nestjs/common'; -import { ConfigModule, ConfigService } from '@nestjs/config'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { MigrationService } from './migration.service'; -import { MigrationController } from './migration.controller'; -import { DatabaseService } from './database.service'; -import { TransactionManagerService } from '../common/transaction-manager.service'; - -/** - * Determines whether TypeORM should auto-synchronize the schema. - * - * Synchronization is allowed only in local development and test environments. - * Deployed environments (staging, production, qa, preview, etc.) must use - * migrations as the only schema-change path, so this returns `false` for any - * environment that is not explicitly allowlisted. - */ -export function shouldSynchronizeSchema(nodeEnv: string | undefined): boolean { - const env = nodeEnv || 'development'; - return ['development', 'test'].includes(env); -} - -Global() -@Module({ - imports: [ - TypeOrmModule.forRootAsync({ - imports: [ConfigModule], - useFactory: (config: ConfigService) => ({ - type: 'postgres', - url: config.get('DATABASE_URL'), - autoLoadEntities: true, - // Schema changes in deployed environments must go through migrations. - synchronize: shouldSynchronizeScchema(config.get('NODE_ENV', 'development')), - ssl: config.get('NODE_ENV') === 'production' ? { rejectUnauthorized: false } : false, - }), - inject: [ConfigService], - }), - ], - controllers: [MigrationController], - providers: [DatabaseService, MigrationService, TransactionManagerService], - exports: [TypeOrmModule, DatabaseService, MigrationService, TransactionManagerService], -}) -export class DatabaseModule {} diff --git a/BackendAcademy/src/database/database.service.spec.ts b/BackendAcademy/src/database/database.service.spec.ts deleted file mode 100644 index 922188124f..0000000000 --- a/BackendAcademy/src/database/database.service.spec.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { DatabaseService } from './database.service'; -import { TransactionManagerService } from '../common/transaction-manager.service'; - -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -describe('DatabaseService — durable webhook idempotency (Issue #663 / BA-095)', () => { - let db: DatabaseService; - - beforeEach(() => { - db = new DatabaseService(new TransactionManagerService()); - }); - - it('claims an unknown key', async () => { - const claim = await db.claimWebhookIdempotency('key-1', 'fp-1'); - expect(claim.claimed).toBe(true); - if (claim.claimed) { - expect(claim.record.status).toBe('in_progress'); - } - }); - - it('rejects a second claim of the same key and fingerprint while in progress', async () => { - await db.claimWebhookIdempotency('key-1', 'fp-1'); - const claim = await db.claimWebhookIdempotency('key-1', 'fp-1'); - expect(claim).toMatchObject({ claimed: false, reason: 'already_in_progress' }); - }); - - it('rejects a completed key as already_processed', async () => { - await db.claimWebhookIdempotency('key-1', 'fp-1'); - await db.completeWebhookIdempotency('key-1'); - const claim = await db.claimWebhookIdempotency('key-1', 'fp-1'); - expect(claim).toMatchObject({ claimed: false, reason: 'already_processed' }); - }); - - it('rejects a key reused with a different payload fingerprint (key_conflict)', async () => { - await db.claimWebhookIdempotency('key-1', 'fp-1'); - const claim = await db.claimWebhookIdempotency('key-1', 'fp-2'); - expect(claim).toMatchObject({ claimed: false, reason: 'key_conflict' }); - }); - - it('re-claims a failed key so the same payload can be retried', async () => { - await db.claimWebhookIdempotency('key-1', 'fp-1'); - await db.failWebhookIdempotency('key-1'); - const claim = await db.claimWebhookIdempotency('key-1', 'fp-1'); - expect(claim.claimed).toBe(true); - }); - - it('re-claims an expired key', async () => { - await db.claimWebhookIdempotency('key-1', 'fp-1', 1); - await sleep(10); - const claim = await db.claimWebhookIdempotency('key-1', 'fp-1', 3_600_000); - expect(claim.claimed).toBe(true); - }); - - it('returns null for unknown keys and completed status after processing', async () => { - expect(await db.getWebhookIdempotency('nope')).toBeNull(); - await db.claimWebhookIdempotency('key-1', 'fp-1'); - await db.completeWebhookIdempotency('key-1'); - const record = await db.getWebhookIdempotency('key-1'); - expect(record?.status).toBe('completed'); - }); -}); - -describe('DatabaseService — durable webhook outbox (Issue #666 / BA-098)', () => { - let db: DatabaseService; - - beforeEach(() => { - db = new DatabaseService(new TransactionManagerService()); - }); - - const record = (id: string, maxRetries = 3) => ({ - id, - url: 'https://example.com/hook', - body: '{"hello":"world"}', - signature: 'sig', - idempotencyKey: `idem-${id}`, - maxRetries, - }); - - it('stores outbound events before delivery', async () => { - await db.enqueueWebhookDelivery(record('w-1')); - const stored = await db.getWebhookOutboxRecord('w-1'); - expect(stored).toMatchObject({ id: 'w-1', status: 'pending', attempts: 0 }); - }); - - it('claims due records atomically and does not re-claim sending/delivered records', async () => { - await db.enqueueWebhookDelivery(record('w-1')); - await db.enqueueWebhookDelivery(record('w-2')); - - const due = await db.claimDueWebhookDeliveries(); - expect(due.map((r) => r.id).sort()).toEqual(['w-1', 'w-2']); - expect(due.every((r) => r.status === 'sending')).toBe(true); - - // A second claim pass must not re-claim the same records. - expect(await db.claimDueWebhookDeliveries()).toEqual([]); - - await db.completeWebhookDelivery('w-1', 200); - expect(await db.claimDueWebhookDeliveries()).toEqual([]); - }); - - it('records a successful delivery as terminal and inspectable', async () => { - await db.enqueueWebhookDelivery(record('w-1')); - await db.claimDueWebhookDeliveries(); - const done = await db.completeWebhookDelivery('w-1', 200); - expect(done).toMatchObject({ status: 'delivered', attempts: 1, lastStatusCode: 200 }); - expect(done?.deliveredAt).toBeInstanceOf(Date); - expect(done?.terminalAt).toBeInstanceOf(Date); - }); - - it('reschedules failed deliveries as retrying with a nextRetryAt', async () => { - await db.enqueueWebhookDelivery(record('w-1', 3)); - await db.claimDueWebhookDeliveries(); - const result = await db.recordWebhookDeliveryFailure('w-1', { - statusCode: 500, - error: 'HTTP 500', - retryDelayMs: 60_000, - }); - expect(result?.terminal).toBe(false); - expect(result?.record.status).toBe('retrying'); - expect(result?.record.attempts).toBe(1); - expect(result?.record.nextRetryAt).toBeInstanceOf(Date); - }); - - it('resumes retrying records once nextRetryAt is due', async () => { - await db.enqueueWebhookDelivery(record('w-1', 3)); - await db.claimDueWebhookDeliveries(); - await db.recordWebhookDeliveryFailure('w-1', { error: 'boom', retryDelayMs: 0 }); - - const due = await db.claimDueWebhookDeliveries(); - expect(due.map((r) => r.id)).toEqual(['w-1']); - expect(due[0].status).toBe('sending'); - }); - - it('marks a record as failed once retries are exhausted and keeps it inspectable', async () => { - await db.enqueueWebhookDelivery(record('w-1', 2)); - await db.claimDueWebhookDeliveries(); - await db.recordWebhookDeliveryFailure('w-1', { error: 'attempt 1', retryDelayMs: 0 }); - await db.claimDueWebhookDeliveries(); - const terminal = await db.recordWebhookDeliveryFailure('w-1', { error: 'attempt 2', retryDelayMs: 0 }); - - expect(terminal?.terminal).toBe(true); - expect(terminal?.record.status).toBe('failed'); - expect(terminal?.record.terminalAt).toBeInstanceOf(Date); - - const failures = await db.getTerminalWebhookFailures(); - expect(failures.map((r) => r.id)).toEqual(['w-1']); - - const listed = await db.listWebhookOutbox({ status: 'failed' }); - expect(listed.map((r) => r.id)).toEqual(['w-1']); - }); - - it('does not enqueue the same webhook twice', async () => { - await db.enqueueWebhookDelivery(record('w-1')); - await db.enqueueWebhookDelivery(record('w-1')); - const all = await db.listWebhookOutbox(); - expect(all).toHaveLength(1); - }); -}); diff --git a/BackendAcademy/src/database/database.service.ts b/BackendAcademy/src/database/database.service.ts deleted file mode 100644 index 7971036610..0000000000 --- a/BackendAcademy/src/database/database.service.ts +++ /dev/null @@ -1,765 +0,0 @@ -import { Injectable, Logger, OnModuleInit, OnApplicationShutdown } from '@nestjs/common'; -import { - TransactionManagerService, - TransactionSnapshot, -} from '../common/transaction-manager.service'; - -export interface CouponRecord { - id: string; - code: string; - discountType: 'percentage' | 'fixed'; - discountValue: number; - maxRedemptions: number; - currentRedemptions: number; - expiresAt: Date | null; - minPurchaseAmount: number; - applicablePlans: string[]; - isActive: boolean; - createdAt: Date; - updatedAt: Date; -} - -export interface RedemptionRecord { - id: string; - couponId: string; - code: string; - userId: string; - amount: number; - discountApplied: number; - redeemedAt: Date; - orderId: string; -} - -/** - * Payment lifecycle states — Issue #412 follow-up. - * - * A payment can only ever move "forward" through this graph. Any callback - * that asks for a transition not present in ALLOWED_TRANSITIONS is rejected, - * regardless of whether its idempotency key has been seen before. This is - * what protects us from duplicate/out-of-order provider callbacks mutating - * state incorrectly (e.g. a delayed `pending` retry arriving after a - * `succeeded` callback, or a `succeeded` callback being re-applied and - * double-granting redemption side effects). - */ -export type PaymentStatus = 'pending' | 'processing' | 'succeeded' | 'failed' | 'refunded'; - -/** - * Every payment status, in declaration order. Exported so transition tests - * and callers can enumerate the full state space without re-listing it. - * - * #661 (BA-093): used by the exhaustive state-transition test suite. - */ -export const PAYMENT_STATUSES: PaymentStatus[] = [ - 'pending', - 'processing', - 'succeeded', - 'failed', - 'refunded', -]; - -export interface PaymentRecord { - id: string; - orderId: string; - userId: string; - status: PaymentStatus; - amount: number; - assetCode: string; - provider: string; - /** Ids of every provider event that has already been applied to this payment. */ - appliedEventIds: string[]; - createdAt: Date; - updatedAt: Date; -} - -export interface PaymentTransitionResult { - success: boolean; - /** True when the requested status was applied and actually changed state. */ - transitioned: boolean; - /** True when this exact event id had already been applied (safe no-op). */ - duplicateEvent?: boolean; - /** True when the payment was already in the requested status (idempotent no-op). */ - alreadyInStatus?: boolean; - reason?: string; - payment?: PaymentRecord; -} - -/** - * Lifecycle of a webhook idempotency key — Issue #663 (BA-095). - * - * Unlike the previous process-local replay maps, claims are stored in the - * database layer and carry a payload fingerprint plus an explicit - * processing status, so a restart (or another replica) cannot lose the - * claim and in-progress work is distinguishable from completed work. - */ -export type WebhookIdempotencyStatus = 'in_progress' | 'completed' | 'failed'; - -export interface WebhookIdempotencyRecord { - idempotencyKey: string; - /** SHA-256 fingerprint of the raw webhook payload. */ - payloadFingerprint: string; - status: WebhookIdempotencyStatus; - firstReceivedAt: Date; - expiresAt: Date; - updatedAt: Date; -} - -export type WebhookIdempotencyClaim = - | { claimed: true; record: WebhookIdempotencyRecord } - | { - claimed: false; - reason: 'already_in_progress' | 'already_processed' | 'key_conflict'; - record: WebhookIdempotencyRecord; - }; - -/** - * Durable outbox record for outbound webhook delivery — Issue #666 (BA-098). - * - * Outbound events are persisted *before* delivery is attempted, retries are - * resumed from `nextRetryAt` instead of a process-local queue, and terminal - * failures stay inspectable via {@link getTerminalWebhookFailures}. - */ -export type WebhookOutboxStatus = - | 'pending' - | 'sending' - | 'retrying' - | 'delivered' - | 'failed'; - -export interface WebhookOutboxRecord { - id: string; - url: string; - body: string; - signature: string; - idempotencyKey: string; - maxRetries: number; - status: WebhookOutboxStatus; - /** Number of delivery attempts made so far (starts at 0). */ - attempts: number; - nextRetryAt: Date | null; - lastError?: string; - lastStatusCode?: number; - createdAt: Date; - updatedAt: Date; - deliveredAt?: Date; - terminalAt?: Date; -} - -export type NewWebhookOutboxRecord = Pick< - WebhookOutboxRecord, - 'id' | 'url' | 'body' | 'signature' | 'idempotencyKey' | 'maxRetries' ->; - -@Injectable() -export class DatabaseService implements OnModuleInit, OnApplicationShutdown { - private readonly logger = new Logger(DatabaseService.name); - private coupons: Map = new Map(); - private redemptions: RedemptionRecord[] = []; - private payments: Map = new Map(); - private migrationsApplied: string[] = []; - /** Durable webhook idempotency claims — Issue #663 (BA-095). */ - private webhookIdempotency: Map = new Map(); - /** Durable webhook delivery outbox — Issue #666 (BA-098). */ - private webhookOutbox: Map = new Map(); - - constructor(private readonly transactionManager: TransactionManagerService) {} - - /** - * Explicit state machine for payment status transitions. A status that - * does not appear as a key has no legal outgoing transitions (terminal). - * - * #661 (BA-093): this is the single, centralized source of truth for legal - * transitions. Both the webhook ingress path - * ({@link PaymentsService.processPaymentWebhookEvent}) and any internal - * status update route through {@link updatePaymentStatus}, which consults - * these rules, so illegal regressions and terminal-state changes are - * rejected everywhere. - */ - static readonly ALLOWED_TRANSITIONS: Record = { - pending: ['processing', 'succeeded', 'failed'], - processing: ['succeeded', 'failed'], - succeeded: ['refunded'], - failed: [], - refunded: [], - }; - - /** - * Returns whether transitioning from `from` to `to` is legal under the - * centralized payment state machine (#661 / BA-093). A no-op - * (`from === to`) is considered legal because callers treat it as an - * idempotent no-op rather than a transition. - */ - static isLegalPaymentTransition(from: PaymentStatus, to: PaymentStatus): boolean { - if (from === to) return true; - const allowed = DatabaseService.ALLOWED_TRANSITIONS[from] ?? []; - return allowed.includes(to); - } - - /** - * Returns whether a status is terminal (has no legal outgoing transitions), - * so any further change from it must be rejected (#661 / BA-093). - */ - static isTerminalPaymentStatus(status: PaymentStatus): boolean { - return (DatabaseService.ALLOWED_TRANSITIONS[status] ?? []).length === 0; - } - - onModuleInit() { - this.seedSampleCoupons(); - this.ensureMigrationTracking(); - } - - /** - * Ensures migration tracking table is initialized. - * Runs as part of startup to guarantee migration order awareness. - */ - private ensureMigrationTracking(): void { - this.migrationsApplied = []; - } - - recordMigrationApplied(name: string): void { - this.migrationsApplied.push(name); - } - - getAppliedMigrations(): string[] { - return [...this.migrationsApplied]; - } - - hasMigrationBeenApplied(name: string): boolean { - return this.migrationsApplied.includes(name); - } - - private seedSampleCoupons() { - const sample: CouponRecord[] = [ - { - id: 'coupon-stellar10', - code: 'STELLAR10', - discountType: 'percentage', - discountValue: 10, - maxRedemptions: 100, - currentRedemptions: 0, - expiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), - minPurchaseAmount: 0, - applicablePlans: [], - isActive: true, - createdAt: new Date(), - updatedAt: new Date(), - }, - { - id: 'coupon-academy25', - code: 'ACADEMY25', - discountType: 'fixed', - discountValue: 25, - maxRedemptions: 50, - currentRedemptions: 0, - expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), - minPurchaseAmount: 100, - applicablePlans: ['premium', 'pro'], - isActive: true, - createdAt: new Date(), - updatedAt: new Date(), - }, - ]; - sample.forEach((c) => this.coupons.set(c.id, c)); - } - - // --------------------------------------------------------------------- - // Health check — Issue #375 - // --------------------------------------------------------------------- - - /** - * Returns true when the database (in-memory store) is operational. - */ - async isHealthy(): Promise { - // The in-memory store is always healthy as long as the process runs. - // In production, this would verify actual DB connectivity (e.g., - // SELECT 1 or a connection pool ping). - return true; - } - - async createCoupon(coupon: CouponRecord): Promise { - this.coupons.set(coupon.id, coupon); - return coupon; - } - - async getCouponById(id: string): Promise { - return this.coupons.get(id) ?? null; - } - - async getCouponByCode(code: string): Promise { - for (const coupon of this.coupons.values()) { - if (coupon.code === code) return coupon; - } - return null; - } - - async getAllCoupons(): Promise { - return Array.from(this.coupons.values()); - } - - async updateCoupon(id: string, updates: Partial): Promise { - const existing = this.coupons.get(id); - if (!existing) return null; - const updated = { ...existing, ...updates, updatedAt: new Date() }; - this.coupons.set(id, updated); - return updated; - } - - async recordRedemption(redemption: RedemptionRecord): Promise { - this.redemptions.push(redemption); - return redemption; - } - - async getRedemptionsByCoupon(couponId: string): Promise { - return this.redemptions.filter((r) => r.couponId === couponId); - } - - async getRedemptionsByUser(userId: string): Promise { - return this.redemptions.filter((r) => r.userId === userId); - } - - async getAllRedemptions(limit = 50): Promise { - return this.redemptions.slice(-limit); - } - - async validateCoupon( - code: string, - userId: string, - amount: number, - ): Promise<{ valid: boolean; reason?: string; coupon?: CouponRecord }> { - const coupon = await this.getCouponByCode(code); - if (!coupon) return { valid: false, reason: 'Coupon not found' }; - if (!coupon.isActive) return { valid: false, reason: 'Coupon is no longer active' }; - if (coupon.expiresAt && coupon.expiresAt < new Date()) - return { valid: false, reason: 'Coupon has expired' }; - if (coupon.currentRedemptions >= coupon.maxRedemptions) - return { valid: false, reason: 'Coupon redemption limit reached' }; - if (amount < coupon.minPurchaseAmount) - return { - valid: false, - reason: `Minimum purchase amount of ${coupon.minPurchaseAmount} not met`, - }; - const userRedemptions = await this.getRedemptionsByUser(userId); - if (userRedemptions.some((r) => r.couponId === coupon.id)) - return { valid: false, reason: 'Coupon already redeemed by this user' }; - return { valid: true, coupon }; - } - - async applyCoupon(code: string, userId: string, amount: number, orderId: string) { - const validation = await this.validateCoupon(code, userId, amount); - if (!validation.valid || !validation.coupon) { - return { success: false, finalAmount: amount, discountApplied: 0, reason: validation.reason }; - } - const coupon = validation.coupon; // now narrowed to CouponRecord, not CouponRecord | undefined - - const discountApplied = - coupon.discountType === 'percentage' - ? Math.round((amount * coupon.discountValue) / 100) - : coupon.discountValue; - const finalAmount = Math.max(0, amount - discountApplied); - - await this.recordRedemption({ - id: `rdm-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - couponId: coupon.id, - code: coupon.code, - userId, - amount, - discountApplied, - redeemedAt: new Date(), - orderId, - }); - await this.updateCoupon(coupon.id, { currentRedemptions: coupon.currentRedemptions + 1 }); - return { success: true, finalAmount, discountApplied }; - } - - /** - * Generic cursor-based pagination helper for in-memory collections. - * Returns a stable page using (createdAt, id) as the sort key. - */ - cursorPaginate( - items: T[], - options: { cursor?: string; limit: number }, - ): { page: T[]; nextCursor?: string } { - const sorted = [...items].sort((a, b) => { - const timeDiff = b.createdAt.getTime() - a.createdAt.getTime(); - if (timeDiff !== 0) return timeDiff; - return b.id.localeCompare(a.id); - }); - - let startIndex = 0; - if (options.cursor) { - const idx = sorted.findIndex((item) => item.id === options.cursor); - if (idx !== -1) startIndex = idx + 1; - } - - const page = sorted.slice(startIndex, startIndex + options.limit); - const nextCursor = page.length === options.limit ? page[page.length - 1].id : undefined; - - return { page, nextCursor }; - } - - // --------------------------------------------------------------------- - // Payment state machine — Issue #412 follow-up - // --------------------------------------------------------------------- - - async createPayment( - payment: Omit, - ): Promise { - const existing = this.payments.get(payment.id); - if (existing) return existing; - const record: PaymentRecord = { - ...payment, - appliedEventIds: [], - createdAt: new Date(), - updatedAt: new Date(), - }; - this.payments.set(record.id, record); - return record; - } - - async getPaymentById(paymentId: string): Promise { - return this.payments.get(paymentId) ?? null; - } - - /** - * Validates and applies a status transition for a payment. - * - * This is the core safeguard for Issue #412: it never trusts the caller's - * claimed "new status" blindly. It always re-reads the payment's *current* - * stored status and checks it against the allowed-transitions graph before - * mutating anything. Two extra layers of duplicate protection are applied - * on top of the transport-level idempotency key check done in the - * controller: - * 1. If this exact provider event id was already applied to this - * payment, the call is a safe no-op (duplicateEvent: true). - * 2. If the payment is already in the requested status, the call is a - * safe no-op (alreadyInStatus: true) — this covers cases where the - * provider sends the same logical event under a different event id. - * Only a genuine, first-time, legal transition returns transitioned: true; - * callers should gate side effects (e.g. granting a coupon redemption) on - * that flag alone. - * - * #358: All mutations are wrapped in a transactional atomic operation. - * If the status update, event ID append, or timestamp update fail, every - * mutation is rolled back so the payment is never left in an - * inconsistent intermediate state. - */ - async updatePaymentStatus( - paymentId: string, - newStatus: PaymentStatus, - eventId: string, - ): Promise { - const payment = this.payments.get(paymentId); - if (!payment) { - return { success: false, transitioned: false, reason: `Payment ${paymentId} not found` }; - } - - if (payment.appliedEventIds.includes(eventId)) { - return { - success: true, - transitioned: false, - duplicateEvent: true, - reason: `Event ${eventId} already applied to payment ${paymentId}`, - payment, - }; - } - - if (payment.status === newStatus) { - // Record the event id so a differently-keyed repeat of the same - // logical callback is also recognized as a duplicate next time. - payment.appliedEventIds.push(eventId); - payment.updatedAt = new Date(); - return { - success: true, - transitioned: false, - alreadyInStatus: true, - reason: `Payment ${paymentId} already in status ${newStatus}`, - payment, - }; - } - - const allowedNext = DatabaseService.ALLOWED_TRANSITIONS[payment.status] ?? []; - if (!allowedNext.includes(newStatus)) { - return { - success: false, - transitioned: false, - reason: `Illegal transition for payment ${paymentId}: ${payment.status} -> ${newStatus}`, - payment, - }; - } - - // Apply the transition atomically — capture pre-mutation state for - // rollback in case any step fails (#358). - const prevStatus = payment.status; - const prevEventIds = [...payment.appliedEventIds]; - const prevUpdatedAt = payment.updatedAt; - - const txResult = await this.transactionManager.runAtomic(async (tx) => { - payment.status = newStatus; - payment.appliedEventIds.push(eventId); - payment.updatedAt = new Date(); - this.payments.set(payment.id, payment); - - await tx.addOperation(async (): Promise => ({ - restore: () => { - payment.status = prevStatus; - payment.appliedEventIds.splice( - payment.appliedEventIds.length - 1, - 1, - ); - payment.updatedAt = prevUpdatedAt; - this.payments.set(payment.id, payment); - }, - data: { paymentId, prevStatus, prevEventIds, prevUpdatedAt }, - })); - - return payment; - }); - - if (!txResult.success) { - this.logger.error( - `Payment status transition failed for ${paymentId}: ${txResult.error?.message}`, - ); - return { - success: false, - transitioned: false, - reason: `Transaction failed: ${txResult.error?.message}`, - payment, - }; - } - - return { success: true, transitioned: true, payment }; - } - - // --------------------------------------------------------------------- - // Durable webhook idempotency — Issue #663 (BA-095) - // --------------------------------------------------------------------- - - /** - * Atomically claims an idempotency key for webhook processing. - * - * The claim is durable (stored in the database layer rather than a - * process-local map), fingerprint-bound to the payload, and carries an - * explicit processing status so in-progress work can be told apart from - * completed work: - * - * - same key + same fingerprint while `in_progress` → already_in_progress - * - same key + same fingerprint while `completed` → already_processed - * - same key + different fingerprint → key_conflict - * - same key + same fingerprint while `failed` → re-claimed (retry) - * - expired key → re-claimed - * - unknown key → claimed - */ - async claimWebhookIdempotency( - idempotencyKey: string, - payloadFingerprint: string, - ttlMs = 3_600_000, - ): Promise { - const now = new Date(); - const existing = this.webhookIdempotency.get(idempotencyKey); - - if (existing && existing.expiresAt > now) { - if (existing.payloadFingerprint !== payloadFingerprint) { - return { claimed: false, reason: 'key_conflict', record: existing }; - } - if (existing.status === 'in_progress') { - return { claimed: false, reason: 'already_in_progress', record: existing }; - } - if (existing.status === 'completed') { - return { claimed: false, reason: 'already_processed', record: existing }; - } - // `failed` falls through: the same payload may legitimately be retried. - } - - const record: WebhookIdempotencyRecord = { - idempotencyKey, - payloadFingerprint, - status: 'in_progress', - firstReceivedAt: existing ? existing.firstReceivedAt : now, - expiresAt: new Date(now.getTime() + ttlMs), - updatedAt: now, - }; - this.webhookIdempotency.set(idempotencyKey, record); - return { claimed: true, record }; - } - - /** - * Marks a webhook idempotency claim as successfully processed. - */ - async completeWebhookIdempotency(idempotencyKey: string): Promise { - const record = this.webhookIdempotency.get(idempotencyKey); - if (record) { - record.status = 'completed'; - record.updatedAt = new Date(); - } - } - - /** - * Marks a webhook idempotency claim as failed (processing errored), which - * allows a subsequent retry of the same payload to re-claim the key. - */ - async failWebhookIdempotency(idempotencyKey: string): Promise { - const record = this.webhookIdempotency.get(idempotencyKey); - if (record) { - record.status = 'failed'; - record.updatedAt = new Date(); - } - } - - /** - * Returns the current claim for an idempotency key, expiring stale - * records on read. - */ - async getWebhookIdempotency( - idempotencyKey: string, - ): Promise { - const record = this.webhookIdempotency.get(idempotencyKey); - if (!record) return null; - if (record.expiresAt <= new Date()) { - this.webhookIdempotency.delete(idempotencyKey); - return null; - } - return record; - } - - // --------------------------------------------------------------------- - // Durable webhook delivery outbox — Issue #666 (BA-098) - // --------------------------------------------------------------------- - - /** - * Persists an outbound webhook *before* delivery is attempted so the - * event cannot be lost on process failure. - */ - async enqueueWebhookDelivery( - input: NewWebhookOutboxRecord, - ): Promise { - const now = new Date(); - const existing = this.webhookOutbox.get(input.id); - if (existing) return existing; - const record: WebhookOutboxRecord = { - ...input, - status: 'pending', - attempts: 0, - nextRetryAt: null, - createdAt: now, - updatedAt: now, - }; - this.webhookOutbox.set(record.id, record); - return record; - } - - /** - * Atomically claims due outbox records for delivery: pending records and - * retrying records whose `nextRetryAt` has passed are flipped to - * `sending` so concurrent workers cannot deliver the same event twice. - */ - async claimDueWebhookDeliveries( - limit = 10, - now = new Date(), - ): Promise { - const due: WebhookOutboxRecord[] = []; - for (const record of this.webhookOutbox.values()) { - if (due.length >= limit) break; - const claimable = - (record.status === 'pending' || record.status === 'retrying') && - (record.nextRetryAt === null || record.nextRetryAt <= now); - if (claimable) { - record.status = 'sending'; - record.updatedAt = now; - due.push(record); - } - } - return due; - } - - /** - * Records a successful delivery attempt. The record becomes terminal - * (`delivered`) and stays inspectable. - */ - async completeWebhookDelivery( - id: string, - statusCode: number, - ): Promise { - const record = this.webhookOutbox.get(id); - if (!record) return null; - const now = new Date(); - record.status = 'delivered'; - record.attempts += 1; - record.lastStatusCode = statusCode; - record.lastError = undefined; - record.nextRetryAt = null; - record.deliveredAt = now; - record.terminalAt = now; - record.updatedAt = now; - return record; - } - - /** - * Records a failed delivery attempt. While attempts remain the record is - * rescheduled (`retrying` + `nextRetryAt`); when attempts are exhausted - * it becomes a terminal `failed` record that stays inspectable. - */ - async recordWebhookDeliveryFailure( - id: string, - options: { statusCode?: number; error?: string; retryDelayMs?: number }, - ): Promise<{ record: WebhookOutboxRecord; terminal: boolean } | null> { - const record = this.webhookOutbox.get(id); - if (!record) return null; - const now = new Date(); - record.attempts += 1; - record.lastStatusCode = options.statusCode; - record.lastError = options.error; - record.updatedAt = now; - - if (record.attempts >= record.maxRetries) { - record.status = 'failed'; - record.terminalAt = now; - record.nextRetryAt = null; - } else { - record.status = 'retrying'; - record.nextRetryAt = new Date( - now.getTime() + (options.retryDelayMs ?? 1_000), - ); - } - return { record, terminal: record.status === 'failed' }; - } - - /** - * Returns a single outbox record by id. - */ - async getWebhookOutboxRecord(id: string): Promise { - return this.webhookOutbox.get(id) ?? null; - } - - /** - * Lists outbox records, optionally filtered by status. - */ - async listWebhookOutbox(filter?: { - status?: WebhookOutboxStatus; - limit?: number; - }): Promise { - let records = Array.from(this.webhookOutbox.values()); - if (filter?.status) { - records = records.filter((r) => r.status === filter.status); - } - records.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); - return filter?.limit ? records.slice(0, filter.limit) : records; - } - - /** - * Returns terminal outbox failures (exhausted retries) for inspection. - */ - async getTerminalWebhookFailures(limit = 50): Promise { - const failed = Array.from(this.webhookOutbox.values()).filter( - (r) => r.status === 'failed', - ); - failed.sort((a, b) => (b.terminalAt?.getTime() ?? 0) - (a.terminalAt?.getTime() ?? 0)); - return failed.slice(0, limit); - } - - onApplicationShutdown(signal?: string) { - this.coupons.clear(); - this.redemptions = []; - this.webhookIdempotency.clear(); - this.webhookOutbox.clear(); - this.logger.log(`DatabaseService shut down gracefully (signal: ${signal}).`); - } -} diff --git a/BackendAcademy/src/database/migration-cli.ts b/BackendAcademy/src/database/migration-cli.ts deleted file mode 100644 index d4615b62bc..0000000000 --- a/BackendAcademy/src/database/migration-cli.ts +++ /dev/null @@ -1,132 +0,0 @@ -/** - * Migration CLI helper. - * - * Provides command-line access to migration preflight validation, - * rollback, and history inspection. - * - * Usage: - * pnpm migration:preflight - * pnpm migration:rollback [--steps=N] [--dry-run] - * pnpm migration:history - * - * This is a thin CLI wrapper around MigrationService for use in - * CI/CD pipelines and manual deployments. - */ - -import { NestFactory } from '@nestjs/core'; -import { AppModule } from '../app.module'; -import { MigrationService } from './migration.service'; - -async function bootstrap() { - const app = await NestFactory.createApplicationContext(AppModule); - const migrationService = app.get(MigrationService); - - const command = process.argv[2] || 'preflight'; - const args = process.argv.slice(3); - - switch (command) { - case 'preflight': { - console.log('🔍 Running migration preflight checks...\n'); - const result = await migrationService.preflightCheck(); - - for (const check of result.checks) { - const icon = - check.severity === 'error' - ? '❌' - : check.severity === 'warning' - ? '⚠️' - : '✅'; - console.log(` ${icon} ${check.name}: ${check.message}`); - } - - if (result.recommendations.length > 0) { - console.log('\n💡 Recommendations:'); - for (const rec of result.recommendations) { - console.log(` - ${rec}`); - } - } - - console.log( - `\n📋 Rollback plan: ${result.rollbackPlan.length} step(s) available`, - ); - for (const step of result.rollbackPlan.slice(0, 3)) { - const rev = step.reversible ? '(reversible)' : '(⚠️ not reversible)'; - console.log(` ${step.order}. ${step.action} ${rev}`); - } - - console.log( - `\n${result.success ? '✅ All preflight checks passed' : '❌ Some checks failed — review before proceeding'}`, - ); - process.exit(result.success ? 0 : 1); - break; - } - - case 'rollback': { - let steps = 1; - let dryRun = false; - for (const arg of args) { - if (arg.startsWith('--steps=')) { - steps = parseInt(arg.split('=')[1], 10) || 1; - } - if (arg === '--dry-run') { - dryRun = true; - } - } - - console.log( - `${dryRun ? '🔄 [DRY RUN] ' : '🔄 '}Rolling back ${steps} migration(s)...\n`, - ); - const result = await migrationService.rollback(steps, dryRun); - - if (result.rolledBack.length > 0) { - console.log('✅ Rolled back:'); - for (const name of result.rolledBack) { - console.log(` - ${name}`); - } - } - - if (result.failed.length > 0) { - console.log('❌ Failed:'); - for (const name of result.failed) { - console.log(` - ${name}`); - } - } - - console.log(`\n${result.message}`); - process.exit(result.success ? 0 : 1); - break; - } - - case 'history': { - console.log('📜 Migration history:\n'); - const history = await migrationService.getMigrationHistory(); - if (history.length === 0) { - console.log(' No migrations recorded.'); - } - for (const m of history) { - const status = m.applied ? 'applied' : 'pending'; - console.log(` ${m.applied ? '✅' : '⏳'} ${m.name} (${status} at ${m.timestamp.toISOString()})`); - } - break; - } - - case 'dry-run': { - console.log('🔄 [DRY RUN] Simulating rollback of 1 migration...\n'); - const result = await migrationService.rollback(1, true); - console.log(result.message); - break; - } - - default: - console.error(`Unknown command: ${command}`); - console.log('Available commands: preflight, rollback, history, dry-run'); - process.exit(1); - } - - await app.close(); -} - -bootstrap().catch((err) => { - console.error('Migration CLI error:', err); - process.exit(1); -}); diff --git a/BackendAcademy/src/database/migration.controller.ts b/BackendAcademy/src/database/migration.controller.ts deleted file mode 100644 index 1ac3696b4b..0000000000 --- a/BackendAcademy/src/database/migration.controller.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Controller, Get, Post, Query } from '@nestjs/common'; -import { MigrationService, PreflightResult, RollbackResult } from './migration.service'; - -@Controller('api/migrations') -export class MigrationController { - constructor(private readonly migrationService: MigrationService) {} - - /** - * Runs preflight validation checks before applying migrations. - */ - @Get('preflight') - async preflight(): Promise { - return this.migrationService.preflightCheck(); - } - - /** - * Returns the rollback plan without executing it. - */ - @Get('rollback-plan') - async getRollbackPlan(@Query('steps') steps?: string): Promise<{ - plan: Awaited>; - }> { - const stepCount = steps ? parseInt(steps, 10) : 5; - return { plan: await this.migrationService.getRollbackPlan(stepCount) }; - } - - /** - * Rolls back the specified number of migrations. - */ - @Post('rollback') - async rollback( - @Query('steps') steps?: string, - ): Promise { - const stepCount = steps ? parseInt(steps, 10) : 1; - return this.migrationService.rollback(stepCount, false); - } - - /** - * Simulates a rollback without executing. - */ - @Post('rollback/dry-run') - async dryRunRollback( - @Query('steps') steps?: string, - ): Promise { - const stepCount = steps ? parseInt(steps, 10) : 1; - return this.migrationService.rollback(stepCount, true); - } - - /** - * Returns the migration history. - */ - @Get('history') - async getHistory() { - return { migrations: await this.migrationService.getMigrationHistory() }; - } -} diff --git a/BackendAcademy/src/database/migration.service.ts b/BackendAcademy/src/database/migration.service.ts deleted file mode 100644 index 619a035c53..0000000000 --- a/BackendAcademy/src/database/migration.service.ts +++ /dev/null @@ -1,237 +0,0 @@ -import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { DataSource } from 'typeorm'; -import { InjectDataSource } from '@nestjs/typeorm'; - -export interface PreflightResult { - success: boolean; - checks: PreflightCheck[]; - recommendations: string[]; - rollbackPlan: RollbackStep[]; -} - -export interface PreflightCheck { - name: string; - passed: boolean; - message: string; - severity: 'info' | 'warning' | 'error'; -} - -export interface RollbackStep { - order: number; - action: string; - reversible: boolean; -} - -export interface MigrationRecord { - id: string; - name: string; - timestamp: Date; - checksum: string; - applied: boolean; - status: 'applied' | 'pending' | 'failed'; -} - -export interface RollbackResult { - success: boolean; - rolledBack: string[]; - failed: string[]; - message: string; -} - -export interface MigrationDependency { - name: string; - dependsOn: string[]; -} - -const MIGRATION_TABLE = 'migration_history'; -const MIGRATION_LOCK_KEY = 'rustacademy:migrations'; - -@Injectable() -export class MigrationService implements OnModuleInit { - private readonly logger = new Logger(MigrationService.name); - private initialized = false; - - private static readonly MIGRATION_ORDER: MigrationDependency[] = [ - { name: 'InitialSchema', dependsOn: [] }, - { name: 'AddUserProfiles', dependsOn: ['InitialSchema'] }, - { name: 'AddCoursesTable', dependsOn: ['AddUserProfiles'] }, - { name: 'AddSubmissionsTable', dependsOn: ['AddCoursesTable'] }, - { name: 'AddNotificationsTable', dependsOn: ['AddUserProfiles'] }, - { name: 'AddPaymentsTable', dependsOn: ['AddUserProfiles'] }, - { name: 'AddReviewsTable', dependsOn: ['AddCoursesTable', 'AddUserProfiles'] }, - ]; - - constructor( - private readonly configService: ConfigService, - @InjectDataSource() private readonly dataSource?: DataSource, - ) {} - - async onModuleInit(): Promise { - await this.ensureMigrationTable(); - } - - async preflightCheck(): Promise { - const checks: PreflightCheck[] = []; - const recommendations: string[] = []; - const dbConnected = await this.checkDatabaseConnectivity(); - checks.push({ - name: 'Database Connectivity', - passed: dbConnected, - message: dbConnected ? 'Successfully connected to the database' : 'Unable to connect to the database', - severity: dbConnected ? 'info' : 'error', - }); - if (!dbConnected) recommendations.push('Verify DATABASE_URL is correctly configured and the database is reachable'); - - const schemaHealthy = await this.checkSchemaState(); - checks.push({ name: 'Schema State', passed: schemaHealthy.passed, message: schemaHealthy.message, severity: schemaHealthy.passed ? 'info' : 'warning' }); - - const pendingMigrations = await this.getPendingMigrations(); - checks.push({ - name: 'Pending Migrations', - passed: true, - message: pendingMigrations.length > 0 ? `${pendingMigrations.length} migration(s) pending: ${pendingMigrations.join(', ')}` : 'No pending migrations', - severity: pendingMigrations.length > 0 ? 'warning' : 'info', - }); - if (pendingMigrations.length > 3) recommendations.push('Consider applying migrations in smaller batches to reduce risk'); - - const envValid = this.validateEnvironment(); - checks.push({ name: 'Environment Configuration', passed: envValid.passed, message: envValid.message, severity: envValid.passed ? 'info' : 'warning' }); - - const orderIssues = await this.validateMigrationOrder(); - checks.push({ name: 'Migration Order', passed: orderIssues.length === 0, message: orderIssues.length > 0 ? `Migration order violations found: ${orderIssues.join('; ')}` : 'Migration ordering and dependencies are valid', severity: orderIssues.length > 0 ? 'error' : 'info' }); - if (orderIssues.length > 0) recommendations.push('Re-order pending migrations to satisfy dependency requirements before applying'); - - const hasDestructiveMigrations = this.checkDestructiveMigrations(pendingMigrations); - checks.push({ name: 'Destructive Migration Risk', passed: !hasDestructiveMigrations, message: hasDestructiveMigrations ? 'Warning: Some pending migrations may be destructive (e.g., DROP COLUMN, DROP TABLE)' : 'No destructive migrations detected', severity: hasDestructiveMigrations ? 'warning' : 'info' }); - if (hasDestructiveMigrations) recommendations.push('Create a database backup before applying destructive migrations'); - - return { success: checks.every((check) => check.severity !== 'error'), checks, recommendations, rollbackPlan: await this.buildRollbackPlan() }; - } - - async getRollbackPlan(steps = 5): Promise { - return (await this.buildRollbackPlan()).slice(0, Math.max(0, steps)); - } - - async rollback(steps = 1, dryRun = false): Promise { - if (!(await this.ensureMigrationTable())) return { success: false, rolledBack: [], failed: [], message: 'Migration history is unavailable' }; - return this.withMigrationLock(async () => { - const applied = (await this.getMigrationHistory()).filter((migration) => migration.applied); - const toRollback = applied.slice(0, Math.min(Math.max(steps, 0), applied.length)); - const rolledBack: string[] = []; - const failed: string[] = []; - this.logger.log(`${dryRun ? '[DRY RUN] ' : ''}Rolling back ${toRollback.length} migration(s): ${toRollback.map((migration) => migration.name).join(', ')}`); - - for (const migration of toRollback) { - try { - if (!dryRun) { - await this.executeRollback(migration); - await this.updateMigrationStatus(migration.name, 'pending'); - } - rolledBack.push(migration.name); - } catch (error) { - this.logger.error(`Failed to roll back migration ${migration.name}: ${(error as Error).message}`); - if (!dryRun) await this.updateMigrationStatus(migration.name, 'failed'); - failed.push(migration.name); - } - } - return { success: failed.length === 0, rolledBack, failed, message: failed.length === 0 ? `Successfully rolled back ${rolledBack.length} migration(s)` : `Rolled back ${rolledBack.length} migration(s), ${failed.length} failed` }; - }); - } - - async getMigrationHistory(): Promise { - if (!(await this.ensureMigrationTable())) return []; - const rows = await this.dataSource!.query(`SELECT id, name, applied_at AS timestamp, checksum, status FROM ${MIGRATION_TABLE} ORDER BY applied_at DESC NULLS LAST, name DESC`); - return rows.map((row: { id: string; name: string; timestamp: Date | string; checksum: string; status: MigrationRecord['status'] }) => ({ - id: row.id, - name: row.name, - timestamp: new Date(row.timestamp), - checksum: row.checksum, - applied: row.status === 'applied', - status: row.status, - })); - } - - async recordMigration(name: string, checksum: string): Promise { - if (!(await this.ensureMigrationTable())) throw new Error('Migration history is unavailable'); - const rows = await this.dataSource!.query(`INSERT INTO ${MIGRATION_TABLE} (name, checksum, status, applied_at) VALUES ($1, $2, 'applied', CURRENT_TIMESTAMP) ON CONFLICT (name) DO UPDATE SET checksum = EXCLUDED.checksum, status = 'applied', applied_at = CURRENT_TIMESTAMP RETURNING id, name, applied_at AS timestamp, checksum, status`, [name, checksum]); - const row = rows[0]; - this.logger.log(`Migration recorded: ${name} (checksum: ${checksum})`); - return { id: row.id, name: row.name, timestamp: new Date(row.timestamp), checksum: row.checksum, applied: true, status: row.status }; - } - - validateMigrationOrder = async (): Promise => { - const issues: string[] = []; - const appliedNames = new Set((await this.getMigrationHistory()).filter((migration) => migration.applied).map((migration) => migration.name)); - const pending = await this.getPendingMigrations(); - const pendingSet = new Set(pending); - for (const migration of MigrationService.MIGRATION_ORDER) { - for (const dependency of migration.dependsOn) { - if ((pendingSet.has(migration.name) || appliedNames.has(migration.name)) && !appliedNames.has(dependency) && !pendingSet.has(dependency)) issues.push(`Migration "${migration.name}" depends on "${dependency}" which is neither applied nor pending`); - if (pendingSet.has(migration.name) && pendingSet.has(dependency) && pending.indexOf(migration.name) < pending.indexOf(dependency)) issues.push(`Migration "${migration.name}" must be applied after its dependency "${dependency}"`); - } - } - return issues; - }; - - getMigrationsInOrder(): string[] { - return MigrationService.MIGRATION_ORDER.map((migration) => migration.name); - } - - private async ensureMigrationTable(): Promise { - if (this.initialized) return true; - if (!this.dataSource) return false; - try { - await this.dataSource.query(`CREATE TABLE IF NOT EXISTS ${MIGRATION_TABLE} (id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name VARCHAR(255) NOT NULL UNIQUE, applied_at TIMESTAMPTZ NULL, checksum VARCHAR(128) NOT NULL, status VARCHAR(16) NOT NULL DEFAULT 'pending', CONSTRAINT migration_history_status_check CHECK (status IN ('applied', 'pending', 'failed')))`, []); - this.initialized = true; - return true; - } catch (error) { - this.logger.error(`Unable to initialize migration history table: ${(error as Error).message}`); - return false; - } - } - - private async withMigrationLock(operation: () => Promise): Promise { - await this.dataSource!.query('SELECT pg_advisory_lock(hashtextextended($1, 0))', [MIGRATION_LOCK_KEY]); - try { return await operation(); } finally { await this.dataSource!.query('SELECT pg_advisory_unlock(hashtextextended($1, 0))', [MIGRATION_LOCK_KEY]); } - } - - private async checkDatabaseConnectivity(): Promise { - if (!this.dataSource) return false; - try { await this.dataSource.query('SELECT 1'); return true; } catch { return false; } - } - - private async checkSchemaState(): Promise<{ passed: boolean; message: string }> { - if (!this.dataSource) return { passed: true, message: 'DataSource not available — skipping schema check' }; - try { const tables = await this.dataSource.query(`SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'`); return { passed: true, message: `Schema OK (${tables.length} table(s) found)` }; } catch { return { passed: true, message: 'Could not inspect schema — proceeding cautiously' }; } - } - - private async getPendingMigrations(): Promise { - const applied = new Set((await this.getMigrationHistory()).filter((migration) => migration.applied).map((migration) => migration.name)); - return this.getMigrationsInOrder().filter((name) => !applied.has(name)); - } - - private validateEnvironment(): { passed: boolean; message: string } { - const dbUrl = this.configService.get('DATABASE_URL'); - if (!dbUrl) return { passed: true, message: 'DATABASE_URL not set (using in-memory fallback) — migrations may be skipped' }; - if (this.configService.get('NODE_ENV') === 'production' && !dbUrl.includes('ssl')) return { passed: true, message: 'DATABASE_URL configured but SSL may not be enabled — verify connection security' }; - return { passed: true, message: 'Environment configuration looks good' }; - } - - private checkDestructiveMigrations(pending: string[]): boolean { - return pending.some((name) => ['DROP', 'TRUNCATE', 'DELETE FROM'].some((keyword) => name.toUpperCase().includes(keyword))); - } - - private async buildRollbackPlan(): Promise { - const applied = new Set((await this.getMigrationHistory()).filter((migration) => migration.applied).map((migration) => migration.name)); - return this.getMigrationsInOrder().filter((name) => applied.has(name)).reverse().map((name, index) => ({ order: index + 1, action: `Roll back migration "${name}"`, reversible: !name.toUpperCase().includes('DROP') })); - } - - private async updateMigrationStatus(name: string, status: MigrationRecord['status']): Promise { - await this.dataSource!.query(`UPDATE ${MIGRATION_TABLE} SET status = $2, applied_at = CASE WHEN $2 = 'applied' THEN CURRENT_TIMESTAMP ELSE applied_at END WHERE name = $1`, [name, status]); - } - - private async executeRollback(migration: MigrationRecord): Promise { - this.logger.log(`Executing rollback for migration: ${migration.name}`); - } -} diff --git a/BackendAcademy/src/dead-letter-queue/dlq.module.ts b/BackendAcademy/src/dead-letter-queue/dlq.module.ts deleted file mode 100644 index e5363ccd15..0000000000 --- a/BackendAcademy/src/dead-letter-queue/dlq.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Module } from '@nestjs/common'; -import { DlqService } from './dlq.service'; - -@Module({ - providers: [DlqService], - exports: [DlqService], -}) -export class DlqModule {} diff --git a/BackendAcademy/src/dead-letter-queue/dlq.service.ts b/BackendAcademy/src/dead-letter-queue/dlq.service.ts deleted file mode 100644 index 0299c714f1..0000000000 --- a/BackendAcademy/src/dead-letter-queue/dlq.service.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; - -export interface DeadLetterRecord { - jobId: string; - jobType: string; - payload: unknown; - error: string; - failedAt: Date; - retryCount: number; - originalQueue: string; - metadata?: Record; -} - -@Injectable() -export class DlqService { - private readonly logger = new Logger(DlqService.name); - private readonly deadLetterQueue: DeadLetterRecord[] = []; - - sendToDlq(record: DeadLetterRecord): void { - this.deadLetterQueue.push(record); - this.logger.warn( - `Job ${record.jobId} (${record.jobType}) sent to DLQ after ${record.retryCount} retries: ${record.error}`, - ); - } - - getDlqRecords(): DeadLetterRecord[] { - return [...this.deadLetterQueue]; - } - - getDlqCount(): number { - return this.deadLetterQueue.length; - } - - replayJob(jobId: string): DeadLetterRecord | null { - const index = this.deadLetterQueue.findIndex(r => r.jobId === jobId); - if (index === -1) return null; - const record = this.deadLetterQueue[index]; - this.deadLetterQueue.splice(index, 1); - this.logger.log(`Job ${jobId} replayed from DLQ`); - return record; - } - - purgeDlq(): number { - const count = this.deadLetterQueue.length; - this.deadLetterQueue.length = 0; - this.logger.log(`DLQ purged: ${count} records removed`); - return count; - } -} diff --git a/BackendAcademy/src/dead-letter-queue/grading-retry-policy.ts b/BackendAcademy/src/dead-letter-queue/grading-retry-policy.ts deleted file mode 100644 index 0a6f9afc9c..0000000000 --- a/BackendAcademy/src/dead-letter-queue/grading-retry-policy.ts +++ /dev/null @@ -1,34 +0,0 @@ -export interface RetryPolicyConfig { - maxAttempts: number; - baseDelayMs: number; -} - -export const DEFAULT_GRADING_RETRY_POLICY: RetryPolicyConfig = { - maxAttempts: 5, - baseDelayMs: 2000, -}; - -export interface DeadLetterEntry { - jobId: string; - attempts: number; - lastError: string; - context: Record; -} - -/** Returns true while a failed grading job should still be retried. */ -export function shouldRetry( - attempts: number, - policy: RetryPolicyConfig = DEFAULT_GRADING_RETRY_POLICY, -): boolean { - return attempts < policy.maxAttempts; -} - -/** Builds the dead-letter record for a permanently failed grading job. */ -export function toDeadLetterEntry( - jobId: string, - attempts: number, - lastError: string, - context: Record = {}, -): DeadLetterEntry { - return { jobId, attempts, lastError, context }; -} diff --git a/BackendAcademy/src/health/health.controller.ts b/BackendAcademy/src/health/health.controller.ts index 97517eb91d..ecc366321c 100644 --- a/BackendAcademy/src/health/health.controller.ts +++ b/BackendAcademy/src/health/health.controller.ts @@ -1,60 +1,16 @@ -import { Controller, Get, Inject, Optional } from '@nestjs/common'; -import { HealthService, ReadinessResult, HealthCheckResult, WorkerReadiness } from './health.service'; -import { JobsService } from '../jobs/jobs.service'; +import { Controller, Get } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +@ApiTags('health') @Controller('health') +//controller for health check endpoint export class HealthController { - constructor( - private readonly health: HealthService, - @Optional() @Inject(JobsService) private readonly jobsService?: JobsService, - ) {} - - /** - * Full health check including all infrastructure dependencies — #375. - * - * Returns the combined health status of Redis, database, and external - * providers so operators can detect degraded services before they cause - * user-facing failures. - */ @Get() - async check(): Promise { - return this.health.check(); - } - - /** - * Kubernetes-style readiness probe — #376. - * - * Evaluates whether this instance is ready to receive traffic by - * checking infrastructure health AND background worker/queue status. - * Load balancers and orchestrators should route traffic only when this - * endpoint returns HTTP 200 (ready: true). - */ - @Get('ready') - async ready(): Promise { - let workerReadiness: WorkerReadiness | undefined; - if (this.jobsService) { - try { - workerReadiness = { - ready: this.jobsService.isReady(), - queueDepth: this.jobsService.getQueueDepth(), - activeWorkers: 1, - lastHeartbeat: this.jobsService.getLastHeartbeat(), - }; - } catch { - // JobsService probe failed — readiness will report degraded - workerReadiness = { ready: false, queueDepth: -1, activeWorkers: 0 }; - } - } - - return this.health.checkReadiness(workerReadiness); - } - - /** - * Lightweight liveness probe — confirms the process is alive without - * checking any external dependencies. - */ - @Get('live') - liveness(): { alive: boolean; timestamp: string } { - return this.health.checkLiveness(); + getHealth() { + return { + status: 'ok', + service: 'backend-academy', + timestamp: new Date().toISOString(), + }; } } diff --git a/BackendAcademy/src/health/health.module.ts b/BackendAcademy/src/health/health.module.ts deleted file mode 100644 index c57fce0393..0000000000 --- a/BackendAcademy/src/health/health.module.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Module } from '@nestjs/common'; -import { HealthController } from './health.controller'; -import { HealthService } from './health.service'; -import { RedisModule } from '../redis/redis.module'; -import { DatabaseModule } from '../database/database.module'; -import { JobsModule } from '../jobs/jobs.module'; - -@Module({ - imports: [RedisModule, DatabaseModule, JobsModule], - controllers: [HealthController], - providers: [HealthService], - exports: [HealthService], -}) -export class HealthModule {} diff --git a/BackendAcademy/src/health/health.service.spec.ts b/BackendAcademy/src/health/health.service.spec.ts deleted file mode 100644 index 22e0ec5ab0..0000000000 --- a/BackendAcademy/src/health/health.service.spec.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { ConfigService } from '@nestjs/config'; -import { HealthService } from './health.service'; -import { RedisService } from '../redis/redis.service'; -import { DatabaseService } from '../database/database.service'; -import { MonitoringService } from '../monitoring/monitoring.service'; - -describe('HealthService', () => { - let service: HealthService; - let redisService: RedisService; - let databaseService: DatabaseService; - let monitoringService: MonitoringService; - - const mockConfigService = { - get: jest.fn((key: string, defaultValue?: unknown) => { - if (key === 'READINESS_PROBE_TIMEOUT_MS') return 1_000; - return defaultValue; - }), - }; - - beforeEach(async () => { - redisService = { - get: jest.fn(), - } as unknown as RedisService; - - databaseService = { - isHealthy: jest.fn(), - } as unknown as DatabaseService; - - monitoringService = {} as MonitoringService; - - const moduleRef: TestingModule = await Test.createTestingModule({ - providers: [ - HealthService, - { provide: ConfigService, useValue: mockConfigService }, - { provide: RedisService, useValue: redisService }, - { provide: DatabaseService, useValue: databaseService }, - { provide: MonitoringService, useValue: monitoringService }, - ], - }).compile(); - - service = moduleRef.get(HealthService); - }); - - describe('checkLiveness', () => { - it('returns alive: true with a timestamp', () => { - const result = service.checkLiveness(); - expect(result.alive).toBe(true); - expect(result.timestamp).toBeDefined(); - expect(new Date(result.timestamp).getTime()).not.toBeNaN(); - }); - }); - - describe('check (full health)', () => { - it('returns ok when all dependencies are healthy', async () => { - (databaseService.isHealthy as jest.Mock).mockResolvedValue(true); - (redisService.get as jest.Mock).mockResolvedValue(null); - - const result = await service.check(); - expect(result.status).toBe('ok'); - expect(result.dependencies).toHaveLength(2); - expect(result.dependencies.every((d) => d.status === 'healthy')).toBe(true); - }); - - it('returns unavailable when Redis is unhealthy', async () => { - (databaseService.isHealthy as jest.Mock).mockResolvedValue(true); - (redisService.get as jest.Mock).mockRejectedValue( - new Error('ECONNREFUSED'), - ); - - const result = await service.check(); - expect(result.status).toBe('unavailable'); - expect(result.dependencies.find((d) => d.name === 'redis')).toMatchObject({ - status: 'unhealthy', - }); - expect(result.dependencies.find((d) => d.name === 'database')).toMatchObject({ - status: 'healthy', - }); - }); - - it('returns degraded when database service is not injected', async () => { - const moduleRef: TestingModule = await Test.createTestingModule({ - providers: [ - HealthService, - { provide: ConfigService, useValue: mockConfigService }, - { provide: RedisService, useValue: redisService }, - // DatabaseService intentionally omitted (undefined) - ], - }).compile(); - - const svc = moduleRef.get(HealthService); - (redisService.get as jest.Mock).mockResolvedValue(null); - - const result = await svc.check(); - expect(result.status).toBe('degraded'); - const dbCheck = result.dependencies.find((d) => d.name === 'database'); - expect(dbCheck).toMatchObject({ - status: 'degraded', - error: 'DatabaseService not available (not injected)', - }); - }); - - it('returns unavailable when both Redis and database are unhealthy', async () => { - (databaseService.isHealthy as jest.Mock).mockRejectedValue( - new Error('FATAL'), - ); - (redisService.get as jest.Mock).mockRejectedValue( - new Error('Connection lost'), - ); - - const result = await service.check(); - expect(result.status).toBe('unavailable'); - }); - }); - - describe('checkReadiness', () => { - it('returns ready when all dependencies are healthy and workers are active', async () => { - (databaseService.isHealthy as jest.Mock).mockResolvedValue(true); - (redisService.get as jest.Mock).mockResolvedValue(null); - - const result = await service.checkReadiness({ - ready: true, - queueDepth: 5, - activeWorkers: 2, - lastHeartbeat: new Date(), - }); - - expect(result.ready).toBe(true); - expect(result.checks).toHaveLength(4); - expect(result.checks.find((c) => c.name === 'workers')).toMatchObject({ - ready: true, - reason: expect.stringContaining('Workers active'), - }); - }); - - it('returns not ready when workers are stalled', async () => { - (databaseService.isHealthy as jest.Mock).mockResolvedValue(true); - (redisService.get as jest.Mock).mockResolvedValue(null); - - const result = await service.checkReadiness({ - ready: false, - queueDepth: 100, - activeWorkers: 0, - lastHeartbeat: new Date(Date.now() - 120_000), - }); - - expect(result.ready).toBe(false); - const workerCheck = result.checks.find((c) => c.name === 'workers'); - expect(workerCheck).toMatchObject({ ready: false }); - }); - - it('returns not ready when database is unhealthy', async () => { - (databaseService.isHealthy as jest.Mock).mockRejectedValue( - new Error('Connection refused to 10.0.0.5:5432'), - ); - (redisService.get as jest.Mock).mockResolvedValue(null); - - const result = await service.checkReadiness(); - - expect(result.ready).toBe(false); - const dbCheck = result.checks.find((c) => c.name === 'database'); - expect(dbCheck?.ready).toBe(false); - // Reason should be sanitized — no IP addresses leaked - expect(dbCheck?.reason).not.toContain('10.0.0.5'); - expect(dbCheck?.reason).not.toContain('5432'); - }); - - it('returns not ready when Redis is unhealthy', async () => { - (databaseService.isHealthy as jest.Mock).mockResolvedValue(true); - (redisService.get as jest.Mock).mockRejectedValue( - new Error('redis://127.0.0.1:6379 connection refused'), - ); - - const result = await service.checkReadiness(); - - expect(result.ready).toBe(false); - const redisCheck = result.checks.find((c) => c.name === 'redis'); - expect(redisCheck?.ready).toBe(false); - // Connection string should be sanitized - expect(redisCheck?.reason).not.toContain('redis://'); - expect(redisCheck?.reason).not.toContain('127.0.0.1'); - }); - - it('returns ready with infrastructure-only when no workerReadiness provided', async () => { - (databaseService.isHealthy as jest.Mock).mockResolvedValue(true); - (redisService.get as jest.Mock).mockResolvedValue(null); - - const result = await service.checkReadiness(); - - expect(result.ready).toBe(true); - expect(result.checks.find((c) => c.name === 'workers')).toBeUndefined(); - }); - - it('sanitizes timeout reasons when probes hang', async () => { - // Simulate slow database by never resolving the readiness check. - // Use a fake ConfigService with a very short timeout for this test. - const fastConfig = { get: jest.fn(() => 50) } as unknown as ConfigService; - const slowDb = { - isHealthy: () => new Promise(() => {}), // never resolves - } as unknown as DatabaseService; - const healthyRedis = { - get: jest.fn().mockResolvedValue(null), - } as unknown as RedisService; - - const moduleRef: TestingModule = await Test.createTestingModule({ - providers: [ - HealthService, - { provide: ConfigService, useValue: fastConfig }, - { provide: RedisService, useValue: healthyRedis }, - { provide: DatabaseService, useValue: slowDb }, - ], - }).compile(); - - const svc = moduleRef.get(HealthService); - const result = await svc.checkReadiness(); - - expect(result.ready).toBe(false); - const dbCheck = result.checks.find((c) => c.name === 'database'); - expect(dbCheck?.ready).toBe(false); - expect(dbCheck?.reason).toContain('timed out'); - }); - }); -}); diff --git a/BackendAcademy/src/health/health.service.ts b/BackendAcademy/src/health/health.service.ts deleted file mode 100644 index e25d212a60..0000000000 --- a/BackendAcademy/src/health/health.service.ts +++ /dev/null @@ -1,358 +0,0 @@ -import { Injectable, Logger, Inject, Optional } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { RedisService } from '../redis/redis.service'; -import { DatabaseService } from '../database/database.service'; -import { MonitoringService, MetricServiceNames } from '../monitoring/monitoring.service'; - -/** - * Individual dependency health status. - */ -export interface DependencyHealth { - name: string; - status: 'healthy' | 'degraded' | 'unhealthy'; - latencyMs: number; - error?: string; - checkedAt: Date; -} - -/** - * Full health check response — #375. - */ -export interface HealthCheckResult { - status: 'ok' | 'degraded' | 'unavailable'; - timestamp: string; - uptime: number; - dependencies: DependencyHealth[]; -} - -/** - * Readiness probe response — #376. - */ -export interface ReadinessResult { - ready: boolean; - timestamp: string; - checks: { - name: string; - ready: boolean; - reason?: string; - }[]; -} - -/** - * Status of job worker readiness — #376. - */ -export interface WorkerReadiness { - ready: boolean; - queueDepth: number; - activeWorkers: number; - lastHeartbeat?: Date; -} - -@Injectable() -export class HealthService { - private readonly logger = new Logger(HealthService.name); - private readonly readinessTimeoutMs: number; - - constructor( - private readonly configService: ConfigService, - @Optional() @Inject(RedisService) private readonly redisService?: RedisService, - @Optional() @Inject(DatabaseService) private readonly databaseService?: DatabaseService, - @Optional() @Inject(MonitoringService) private readonly monitoringService?: MonitoringService, - ) { - this.readinessTimeoutMs = this.configService.get( - 'READINESS_PROBE_TIMEOUT_MS', - 5_000, - ); - } - - // ────────────────────────────────────────────────────────────────── - // #375: Full dependency health check - // ────────────────────────────────────────────────────────────────── - - /** - * Performs a comprehensive health check across all infrastructure - * dependencies (Redis, Postgres/Database, external providers). - * - * Previously the health endpoint only validated HTTP availability. - * Now it actively probes every dependency and reports degraded or - * unavailable services so operators can detect issues before they - * cascade into user-facing failures. - */ - async check(): Promise { - const checks = await Promise.all([ - this.checkRedis(), - this.checkDatabase(), - ]); - - const degraded = checks.filter((c) => c.status === 'degraded').length; - const unhealthy = checks.filter((c) => c.status === 'unhealthy').length; - - let overall: HealthCheckResult['status']; - if (unhealthy > 0) { - overall = 'unavailable'; - } else if (degraded > 0) { - overall = 'degraded'; - } else { - overall = 'ok'; - } - - return { - status: overall, - timestamp: new Date().toISOString(), - uptime: process.uptime(), - dependencies: checks, - }; - } - - /** - * Extended readiness check that probes database, Redis, job queues, - * and external provider availability — #376. - * - * Each dependency is wrapped in a timeout so a stuck probe cannot - * block the readiness endpoint indefinitely. Error reasons are - * sanitized to avoid leaking sensitive connection details. - */ - async checkReadiness(workerReadiness?: WorkerReadiness): Promise { - const checks: ReadinessResult['checks'] = []; - - // Check dependency health first — if infra is down we are NOT ready. - // Wrap with timeout so a stuck dependency probe cannot block the entire readiness endpoint. - let fullHealth: HealthCheckResult; - try { - fullHealth = await withTimeout(this.check(), this.readinessTimeoutMs, 'health-check'); - } catch { - fullHealth = { - status: 'unavailable', - timestamp: new Date().toISOString(), - uptime: process.uptime(), - dependencies: [], - }; - } - const infraReady = fullHealth.status === 'ok' || fullHealth.status === 'degraded'; - checks.push({ - name: 'infrastructure', - ready: infraReady, - reason: infraReady - ? 'All dependencies healthy or degraded' - : 'One or more dependencies unavailable', - }); - - // Check worker readiness if provided by the jobs service. - if (workerReadiness) { - checks.push({ - name: 'workers', - ready: workerReadiness.ready, - reason: workerReadiness.ready - ? `Workers active (${workerReadiness.activeWorkers}), queue depth: ${workerReadiness.queueDepth}` - : workerReadiness.lastHeartbeat - ? `Workers stalled — last heartbeat at ${workerReadiness.lastHeartbeat.toISOString()}` - : 'No workers active', - }); - } - - // Check individual database readiness with timeout - const dbCheck = await this.checkDatabaseReadiness(); - checks.push(dbCheck); - - // Check individual Redis readiness with timeout - const redisCheck = await this.checkRedisReadiness(); - checks.push(redisCheck); - - const allReady = checks.every((c) => c.ready); - - return { - ready: allReady, - timestamp: new Date().toISOString(), - checks, - }; - } - - /** - * Database readiness probe with timeout. Returns a sanitized error - * reason on failure so sensitive connection details are never leaked. - */ - private async checkDatabaseReadiness(): Promise { - try { - const result = await withTimeout( - this.checkDatabase(), - this.readinessTimeoutMs, - 'database', - ); - return { - name: 'database', - ready: result.status === 'healthy', - reason: result.status === 'healthy' - ? `Database responsive in ${result.latencyMs}ms` - : sanitizeReason(result.error ?? 'Database unhealthy'), - }; - } catch (err) { - return { - name: 'database', - ready: false, - reason: sanitizeReason((err as Error).message), - }; - } - } - - /** - * Redis readiness probe with timeout. Returns a sanitized error - * reason on failure so sensitive connection details are never leaked. - */ - private async checkRedisReadiness(): Promise { - try { - const result = await withTimeout( - this.checkRedis(), - this.readinessTimeoutMs, - 'redis', - ); - return { - name: 'redis', - ready: result.status === 'healthy', - reason: result.status === 'healthy' - ? `Redis responsive in ${result.latencyMs}ms` - : sanitizeReason(result.error ?? 'Redis unhealthy'), - }; - } catch (err) { - return { - name: 'redis', - ready: false, - reason: sanitizeReason((err as Error).message), - }; - } - } - - /** - * Probes Redis connectivity and latency. - */ - private async checkRedis(): Promise { - const startedAt = Date.now(); - try { - if (!this.redisService) { - return { - name: 'redis', - status: 'degraded', - latencyMs: 0, - error: 'RedisService not available (not injected)', - checkedAt: new Date(), - }; - } - - // Probe Redis by reading a known key — a no-op read is sufficient - // to validate connectivity and measure latency. - await this.redisService.get('__health_check__'); - return { - name: 'redis', - status: 'healthy', - latencyMs: Date.now() - startedAt, - checkedAt: new Date(), - }; - } catch (err) { - this.logger.warn(`Redis health check failed: ${(err as Error).message}`); - return { - name: 'redis', - status: 'unhealthy', - latencyMs: Date.now() - startedAt, - error: (err as Error).message, - checkedAt: new Date(), - }; - } - } - - /** - * Probes database connectivity and latency. - */ - private async checkDatabase(): Promise { - const startedAt = Date.now(); - try { - if (!this.databaseService) { - return { - name: 'database', - status: 'degraded', - latencyMs: 0, - error: 'DatabaseService not available (not injected)', - checkedAt: new Date(), - }; - } - - const healthy = await this.databaseService.isHealthy(); - if (!healthy) { - return { - name: 'database', - status: 'unhealthy', - latencyMs: Date.now() - startedAt, - error: 'Database health check returned false', - checkedAt: new Date(), - }; - } - - return { - name: 'database', - status: 'healthy', - latencyMs: Date.now() - startedAt, - checkedAt: new Date(), - }; - } catch (err) { - this.logger.warn( - `Database health check failed: ${(err as Error).message}`, - ); - return { - name: 'database', - status: 'unhealthy', - latencyMs: Date.now() - startedAt, - error: (err as Error).message, - checkedAt: new Date(), - }; - } - } - - /** - * Lightweight liveness probe — simply confirms the process is alive. - */ - checkLiveness(): { alive: boolean; timestamp: string } { - return { - alive: true, - timestamp: new Date().toISOString(), - }; - } -} - -// ────────────────────────────────────────────────────────────────── -// Helpers -// ────────────────────────────────────────────────────────────────── - -/** - * Races a promise against a timeout. Resolves the promise or rejects - * with a descriptive timeout error after `ms` milliseconds. - */ -function withTimeout(promise: Promise, ms: number, label: string): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - reject(new Error(`${label} readiness check timed out after ${ms}ms`)); - }, ms); - - promise - .then((value) => { - clearTimeout(timer); - resolve(value); - }) - .catch((err) => { - clearTimeout(timer); - reject(err); - }); - }); -} - -/** - * Sanitize an error message so that sensitive connection details - * (host, port, credentials) are never exposed in readiness probes. - */ -function sanitizeReason(raw: string): string { - // Strip anything that looks like a connection string or host:port pair - return raw - .replace(/mongodb:\/\/[^\s]*/gi, '[connection]') - .replace(/postgres:\/\/[^\s]*/gi, '[connection]') - .replace(/redis:\/\/[^\s]*/gi, '[connection]') - .replace(/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/g, '[host]') - .replace(/:\d{2,5}\b/g, ':[port]') - .slice(0, 200); -} diff --git a/BackendAcademy/src/hints/dto/get-hint.dto.ts b/BackendAcademy/src/hints/dto/get-hint.dto.ts deleted file mode 100644 index 80dacc335f..0000000000 --- a/BackendAcademy/src/hints/dto/get-hint.dto.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { IsString, IsEnum, IsOptional } from 'class-validator'; -import { HintDifficultyTier } from '../interfaces/hint.interface'; - -export class GetHintDto { - @IsString() - userId: string; - - @IsEnum(HintDifficultyTier) - @IsOptional() - tier?: HintDifficultyTier; -} diff --git a/BackendAcademy/src/hints/hint.controller.ts b/BackendAcademy/src/hints/hint.controller.ts deleted file mode 100644 index f9779ba956..0000000000 --- a/BackendAcademy/src/hints/hint.controller.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { - BadRequestException, - Body, - Controller, - Get, - HttpCode, - HttpStatus, - Param, - Post, - Query, -} from '@nestjs/common'; -import { HintService } from './hint.service'; -import { GetHintDto } from './dto/get-hint.dto'; -import { - HintDifficultyTier, - HintResponse, -} from './interfaces/hint.interface'; - -@Controller('hints') -export class HintController { - constructor(private readonly hintService: HintService) {} - - /** - * POST /hints/:challengeId - * - * Retrieve a hint for a learner on a specific challenge. - * - * Body: - * userId (string, required) – ID of the requesting learner. - * tier (enum, optional) – Requested difficulty tier. Defaults to the - * highest tier the learner has unlocked. - * - * Query (alternative to body tier): - * tier (string) – Same as body tier but via query parameter. - * - * @throws NotFoundException – Challenge not registered. - * @throws ForbiddenException – Learner not enrolled in the owning course. - * @throws BadRequestException – Tier not unlocked, cooldown active, or - * view limit exceeded. - */ - @Post(':challengeId') - @HttpCode(HttpStatus.OK) - getHint( - @Param('challengeId') challengeId: string, - @Body() dto: GetHintDto, - @Query('tier') queryTier?: HintDifficultyTier, - ): HintResponse { - if (!dto.userId?.trim()) { - throw new BadRequestException({ - error: 'INVALID_HINT_INPUT', - message: 'userId is required', - }); - } - - const tier = (dto.tier ?? queryTier) as HintDifficultyTier | undefined; - - if (tier && !Object.values(HintDifficultyTier).includes(tier)) { - throw new BadRequestException({ - error: 'INVALID_HINT_TIER', - message: `Invalid tier "${tier}". Must be one of: ${Object.values(HintDifficultyTier).join(', ')}`, - }); - } - - // We need the attempt count — the controller will ask the service for - // the current attempt info via the ChallengesService. For now the - // caller passes it explicitly; in a full integration the controller - // would inject ChallengesService. The hint service accepts it as a - // parameter to stay decoupled. - // - // For the REST API we accept attemptCount from the query string so the - // endpoint is self-contained. In production this would come from the - // ChallengesService. - return this.hintService.getHint(challengeId, dto.userId, { - tier, - attemptCount: 0, // Caller-driven via challenge attempt info - }); - } - - /** - * GET /hints/:challengeId/available - * - * Returns the list of hint tiers available to a learner for a challenge, - * without actually serving a hint (useful for UI preview). - */ - @Get(':challengeId/available') - getAvailableTiers( - @Param('challengeId') challengeId: string, - @Query('userId') userId: string, - ): { challengeId: string; availableTiers: HintDifficultyTier[] } { - if (!userId?.trim()) { - throw new BadRequestException({ - error: 'INVALID_HINT_INPUT', - message: 'userId query parameter is required', - }); - } - - const courseId = this.hintService.getCourseForChallenge(challengeId); - if (!courseId) { - throw new BadRequestException({ - error: 'HINT_CHALLENGE_NOT_FOUND', - message: `Challenge "${challengeId}" is not registered`, - }); - } - - if (!this.hintService.isEnrolled(userId, courseId)) { - throw new BadRequestException({ - error: 'HINT_CHALLENGE_INACCESSIBLE', - message: 'You are not enrolled in the course that owns this challenge', - }); - } - - return { - challengeId, - availableTiers: Object.values(HintDifficultyTier), - }; - } -} diff --git a/BackendAcademy/src/hints/hint.module.spec.ts b/BackendAcademy/src/hints/hint.module.spec.ts deleted file mode 100644 index 6a6a44c447..0000000000 --- a/BackendAcademy/src/hints/hint.module.spec.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Test } from '@nestjs/testing'; -import { HintController } from './hint.controller'; -import { HintsModule } from './hint.module'; -import { HintService } from './hint.service'; - -describe('HintsModule', () => { - it('registers hint controller and service', async () => { - const moduleRef = await Test.createTestingModule({ - imports: [HintsModule], - }).compile(); - - expect(moduleRef.get(HintController)).toBeInstanceOf(HintController); - expect(moduleRef.get(HintService)).toBeInstanceOf(HintService); - }); -}); diff --git a/BackendAcademy/src/hints/hint.module.ts b/BackendAcademy/src/hints/hint.module.ts deleted file mode 100644 index 0a721e592b..0000000000 --- a/BackendAcademy/src/hints/hint.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from '@nestjs/common'; -import { HintController } from './hint.controller'; -import { HintService } from './hint.service'; - -@Module({ - controllers: [HintController], - providers: [HintService], - exports: [HintService], -}) -export class HintsModule {} diff --git a/BackendAcademy/src/hints/hint.service.spec.ts b/BackendAcademy/src/hints/hint.service.spec.ts deleted file mode 100644 index 944b85d491..0000000000 --- a/BackendAcademy/src/hints/hint.service.spec.ts +++ /dev/null @@ -1,566 +0,0 @@ -import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common'; -import { HintService } from './hint.service'; -import { - HintDifficultyTier, - TIER_UNLOCK_THRESHOLDS, -} from './interfaces/hint.interface'; - -describe('HintService', () => { - let service: HintService; - - /** Helper: set up a challenge in a course with a user enrolled. */ - function setupAccessibleUser( - userId = 'learner-1', - challengeId = 'challenge-1', - courseId = 'course-1', - ) { - service.registerChallenge(challengeId, courseId); - service.enrollUser(userId, courseId); - } - - /** Helper: add hints for a challenge at all tiers. */ - function addAllTierHints(challengeId = 'challenge-1') { - service.addHint({ - challengeId, - tier: HintDifficultyTier.Nudge, - content: 'Think about the edge cases.', - label: 'Gentle nudge', - }); - service.addHint({ - challengeId, - tier: HintDifficultyTier.Suggestion, - content: 'Use a hash map to track seen values.', - label: 'Directional hint', - }); - service.addHint({ - challengeId, - tier: HintDifficultyTier.Solution, - content: 'Iterate with a Set for O(1) lookups.', - label: 'Full solution', - }); - } - - beforeEach(() => { - service = new HintService(); - }); - - // ========================================================================= - // Challenge access enforcement - // ========================================================================= - - describe('challenge access', () => { - it('throws NotFoundException when the challenge is not registered', () => { - expect(() => - service.getHint('unknown-challenge', 'user-1', { attemptCount: 0 }), - ).toThrow(NotFoundException); - }); - - it('throws ForbiddenException when the user is not enrolled', () => { - service.registerChallenge('ch-1', 'course-1'); - // Do NOT enroll the user - - expect(() => - service.getHint('ch-1', 'user-1', { attemptCount: 0 }), - ).toThrow(ForbiddenException); - }); - - it('succeeds when user is enrolled in the owning course', () => { - setupAccessibleUser(); - addAllTierHints(); - - const response = service.getHint('challenge-1', 'learner-1', { - attemptCount: 0, - }); - - expect(response.hintId).toBeTruthy(); - expect(response.isFallback).toBe(false); - }); - - it('rejects a user enrolled in a different course', () => { - service.registerChallenge('ch-1', 'course-1'); - service.enrollUser('user-1', 'course-2'); // wrong course - - expect(() => - service.getHint('ch-1', 'user-1', { attemptCount: 0 }), - ).toThrow(ForbiddenException); - }); - - it('allows access when enrolled in the correct course among many', () => { - service.registerChallenge('challenge-1', 'course-1'); - service.enrollUser('learner-1', 'course-1'); - service.enrollUser('learner-1', 'course-2'); - addAllTierHints(); - - const response = service.getHint('challenge-1', 'learner-1', { - attemptCount: 0, - }); - expect(response.isFallback).toBe(false); - }); - }); - - // ========================================================================= - // Tier bounds enforcement - // ========================================================================= - - describe('tier bounds', () => { - beforeEach(() => { - setupAccessibleUser(); - addAllTierHints(); - }); - - it('returns nudge tier when attemptCount=0 and no tier specified', () => { - const response = service.getHint('challenge-1', 'learner-1', { - attemptCount: 0, - }); - expect(response.tier).toBe(HintDifficultyTier.Nudge); - }); - - it('returns suggestion tier when attemptCount=1', () => { - const response = service.getHint('challenge-1', 'learner-1', { - attemptCount: 1, - }); - expect(response.tier).toBe(HintDifficultyTier.Suggestion); - }); - - it('returns solution tier when attemptCount=2', () => { - const response = service.getHint('challenge-1', 'learner-1', { - attemptCount: 2, - }); - expect(response.tier).toBe(HintDifficultyTier.Solution); - }); - - it('caps at suggestion when requesting solution with attemptCount=1', () => { - const response = service.getHint('challenge-1', 'learner-1', { - tier: HintDifficultyTier.Solution, - attemptCount: 1, - }); - // Cannot get solution; should fall back to suggestion (highest unlocked) - expect(response.tier).toBe(HintDifficultyTier.Suggestion); - }); - - it('caps at nudge when requesting solution with attemptCount=0', () => { - const response = service.getHint('challenge-1', 'learner-1', { - tier: HintDifficultyTier.Solution, - attemptCount: 0, - }); - expect(response.tier).toBe(HintDifficultyTier.Nudge); - }); - - it('returns the exact requested tier when it is unlocked', () => { - const response = service.getHint('challenge-1', 'learner-1', { - tier: HintDifficultyTier.Suggestion, - attemptCount: 1, - }); - expect(response.tier).toBe(HintDifficultyTier.Suggestion); - }); - - it('getUnlockedTier returns nudge for 0 attempts', () => { - expect(service.getUnlockedTier(0)).toBe(HintDifficultyTier.Nudge); - }); - - it('getUnlockedTier returns suggestion for 1 attempt', () => { - expect(service.getUnlockedTier(1)).toBe(HintDifficultyTier.Suggestion); - }); - - it('getUnlockedTier returns solution for 2+ attempts', () => { - expect(service.getUnlockedTier(2)).toBe(HintDifficultyTier.Solution); - expect(service.getUnlockedTier(10)).toBe(HintDifficultyTier.Solution); - }); - }); - - // ========================================================================= - // Fallback behaviour - // ========================================================================= - - describe('fallback behaviour', () => { - beforeEach(() => { - setupAccessibleUser(); - }); - - it('returns generic fallback when no hints are registered for the challenge', () => { - const response = service.getHint('challenge-1', 'learner-1', { - attemptCount: 0, - }); - - expect(response.isFallback).toBe(true); - expect(response.hintId).toBe('fallback'); - expect(response.content).toBeTruthy(); - expect(response.tier).toBe(HintDifficultyTier.Nudge); - }); - - it('falls back to nudge when only solution hints exist and attemptCount=0', () => { - service.addHint({ - challengeId: 'challenge-1', - tier: HintDifficultyTier.Solution, - content: 'Use a hash map.', - }); - - const response = service.getHint('challenge-1', 'learner-1', { - attemptCount: 0, - }); - - // No hints at nudge or suggestion tier → generic fallback - expect(response.isFallback).toBe(true); - }); - - it('falls back to the highest available tier below requested', () => { - // Only nudge hint exists, user requests suggestion - service.addHint({ - challengeId: 'challenge-1', - tier: HintDifficultyTier.Nudge, - content: 'Think about edge cases.', - label: 'Nudge only', - }); - - const response = service.getHint('challenge-1', 'learner-1', { - tier: HintDifficultyTier.Suggestion, - attemptCount: 1, - }); - - expect(response.tier).toBe(HintDifficultyTier.Nudge); - expect(response.label).toBe('Nudge only'); - expect(response.isFallback).toBe(false); - }); - - it('falls back to suggestion when solution is requested but only suggestion exists', () => { - service.addHint({ - challengeId: 'challenge-1', - tier: HintDifficultyTier.Suggestion, - content: 'Use a Set for tracking.', - }); - - const response = service.getHint('challenge-1', 'learner-1', { - tier: HintDifficultyTier.Solution, - attemptCount: 2, - }); - - expect(response.tier).toBe(HintDifficultyTier.Suggestion); - expect(response.isFallback).toBe(false); - }); - }); - - // ========================================================================= - // Anti-spoiler / repeated request enforcement - // ========================================================================= - - describe('anti-spoiler', () => { - beforeEach(() => { - setupAccessibleUser(); - addAllTierHints(); - service.setCooldownMs(0); - }); - - it('increments viewCount on each retrieval', () => { - const first = service.getHint('challenge-1', 'learner-1', { - attemptCount: 0, - }); - expect(first.viewCount).toBe(1); - - const second = service.getHint('challenge-1', 'learner-1', { - attemptCount: 0, - }); - expect(second.viewCount).toBe(2); - }); - - it('throws when maxViews is exceeded', () => { - service.setMaxViews(2); - - service.getHint('challenge-1', 'learner-1', { attemptCount: 0 }); - service.getHint('challenge-1', 'learner-1', { attemptCount: 0 }); - - expect(() => - service.getHint('challenge-1', 'learner-1', { attemptCount: 0 }), - ).toThrow(BadRequestException); - }); - - it('respects custom maxViews of 1', () => { - service.setMaxViews(1); - - service.getHint('challenge-1', 'learner-1', { attemptCount: 0 }); - - expect(() => - service.getHint('challenge-1', 'learner-1', { attemptCount: 0 }), - ).toThrow(BadRequestException); - }); - - it('allows unlimited views when maxViews=0', () => { - service.setMaxViews(0); - - for (let i = 0; i < 20; i++) { - const resp = service.getHint('challenge-1', 'learner-1', { - attemptCount: 0, - }); - expect(resp.viewCount).toBe(i + 1); - } - }); - - it('enforces cooldown between hint requests', () => { - // Override the cooldown set in beforeEach - service.setCooldownMs(5000); - - service.getHint('challenge-1', 'learner-1', { attemptCount: 0 }); - - expect(() => - service.getHint('challenge-1', 'learner-1', { attemptCount: 0 }), - ).toThrow(BadRequestException); - }); - - it('allows request after cooldown expires', async () => { - // Override the cooldown set in beforeEach - service.setCooldownMs(50); - - service.getHint('challenge-1', 'learner-1', { attemptCount: 0 }); - - // Wait for cooldown to expire - await new Promise((resolve) => setTimeout(resolve, 60)); - - const response = service.getHint('challenge-1', 'learner-1', { - attemptCount: 0, - }); - expect(response.viewCount).toBe(2); - }); - - it('returns nextAvailableAt in cooldown error', () => { - // Override the cooldown set in beforeEach - service.setCooldownMs(5000); - service.getHint('challenge-1', 'learner-1', { attemptCount: 0 }); - - try { - service.getHint('challenge-1', 'learner-1', { attemptCount: 0 }); - fail('Expected BadRequestException'); - } catch (err) { - expect(err).toBeInstanceOf(BadRequestException); - const response = (err as BadRequestException).getResponse() as any; - expect(response.nextAvailableAt).toBeGreaterThan(Date.now()); - } - }); - - it('tracks view count across requests', () => { - // Cooldown is already 0 from beforeEach - service.getHint('challenge-1', 'learner-1', { attemptCount: 0 }); - service.getHint('challenge-1', 'learner-1', { attemptCount: 0 }); - service.getHint('challenge-1', 'learner-1', { attemptCount: 0 }); - - const hints = service.getHintsForChallenge('challenge-1'); - const record = service.getViewRecord('learner-1', hints[0].id); - expect(record?.viewCount).toBe(3); - }); - - it('resets view tracking via getViewRecord', () => { - service.getHint('challenge-1', 'learner-1', { attemptCount: 0 }); - - const hints = service.getHintsForChallenge('challenge-1'); - const record = service.getViewRecord('learner-1', hints[0].id); - expect(record?.viewCount).toBe(1); - }); - }); - - // ========================================================================= - // Configuration validation - // ========================================================================= - - describe('configuration', () => { - it('rejects negative cooldown', () => { - expect(() => service.setCooldownMs(-1)).toThrow(BadRequestException); - }); - - it('rejects NaN cooldown', () => { - expect(() => service.setCooldownMs(NaN)).toThrow(BadRequestException); - }); - - it('rejects negative maxViews', () => { - expect(() => service.setMaxViews(-1)).toThrow(BadRequestException); - }); - - it('rejects non-integer maxViews', () => { - expect(() => service.setMaxViews(3.5)).toThrow(BadRequestException); - }); - - it('accepts zero cooldown (no cooldown)', () => { - expect(() => service.setCooldownMs(0)).not.toThrow(); - expect(service.getCooldownMs()).toBe(0); - }); - - it('accepts zero maxViews (unlimited)', () => { - expect(() => service.setMaxViews(0)).not.toThrow(); - expect(service.getMaxViews()).toBe(0); - }); - }); - - // ========================================================================= - // Input validation - // ========================================================================= - - describe('input validation', () => { - it('throws on empty challengeId', () => { - expect(() => - service.getHint('', 'user-1', { attemptCount: 0 }), - ).toThrow(BadRequestException); - }); - - it('throws on empty userId', () => { - service.registerChallenge('ch-1', 'course-1'); - expect(() => - service.getHint('ch-1', '', { attemptCount: 0 }), - ).toThrow(BadRequestException); - }); - - it('throws on whitespace-only challengeId', () => { - expect(() => - service.getHint(' ', 'user-1', { attemptCount: 0 }), - ).toThrow(BadRequestException); - }); - - it('throws on empty courseId in registerChallenge', () => { - expect(() => service.registerChallenge('ch-1', '')).toThrow( - BadRequestException, - ); - }); - - it('throws on empty courseId in enrollUser', () => { - expect(() => service.enrollUser('user-1', '')).toThrow( - BadRequestException, - ); - }); - }); - - // ========================================================================= - // Enrollment and challenge registration - // ========================================================================= - - describe('enrollment and registration', () => { - it('registerChallenge stores challenge-course mapping', () => { - service.registerChallenge('ch-1', 'course-1'); - expect(service.getCourseForChallenge('ch-1')).toBe('course-1'); - }); - - it('getCourseForChallenge returns null for unknown challenge', () => { - expect(service.getCourseForChallenge('unknown')).toBeNull(); - }); - - it('enrollUser grants access', () => { - service.enrollUser('user-1', 'course-1'); - expect(service.isEnrolled('user-1', 'course-1')).toBe(true); - }); - - it('isEnrolled returns false for non-enrolled user', () => { - expect(service.isEnrolled('user-1', 'course-1')).toBe(false); - }); - - it('multiple enrollments are tracked independently', () => { - service.enrollUser('user-1', 'course-1'); - service.enrollUser('user-1', 'course-2'); - - expect(service.isEnrolled('user-1', 'course-1')).toBe(true); - expect(service.isEnrolled('user-1', 'course-2')).toBe(true); - expect(service.isEnrolled('user-2', 'course-1')).toBe(false); - }); - }); - - // ========================================================================= - // Hint definition management - // ========================================================================= - - describe('hint definitions', () => { - it('addHint stores and returns the hint with generated id', () => { - const hint = service.addHint({ - challengeId: 'ch-1', - tier: HintDifficultyTier.Nudge, - content: 'Test hint', - }); - - expect(hint.id).toBeTruthy(); - expect(hint.challengeId).toBe('ch-1'); - expect(hint.tier).toBe(HintDifficultyTier.Nudge); - }); - - it('addHint preserves provided id', () => { - const hint = service.addHint({ - id: 'custom-id', - challengeId: 'ch-1', - tier: HintDifficultyTier.Nudge, - content: 'Test hint', - }); - - expect(hint.id).toBe('custom-id'); - }); - - it('getHintsForChallenge returns all hints for a challenge', () => { - addAllTierHints('ch-1'); - const hints = service.getHintsForChallenge('ch-1'); - expect(hints).toHaveLength(3); - }); - - it('getHintsForChallenge returns empty array for unknown challenge', () => { - expect(service.getHintsForChallenge('unknown')).toEqual([]); - }); - }); - - // ========================================================================= - // Reset behaviour - // ========================================================================= - - describe('resetAll', () => { - it('clears all state', () => { - setupAccessibleUser(); - addAllTierHints(); - service.getHint('challenge-1', 'learner-1', { attemptCount: 0 }); - - service.resetAll(); - - expect(service.getCourseForChallenge('challenge-1')).toBeNull(); - expect(service.isEnrolled('learner-1', 'course-1')).toBe(false); - expect(service.getHintsForChallenge('challenge-1')).toEqual([]); - }); - }); - - // ========================================================================= - // Integration: full flow scenario - // ========================================================================= - - describe('full flow scenario', () => { - it('learner progresses through tiers as they attempt the challenge', () => { - setupAccessibleUser('alice', 'rust-101', 'rust-course'); - addAllTierHints('rust-101'); - service.setCooldownMs(0); - - // 0 attempts → nudge - const r1 = service.getHint('rust-101', 'alice', { attemptCount: 0 }); - expect(r1.tier).toBe(HintDifficultyTier.Nudge); - expect(r1.isFallback).toBe(false); - - // 1 attempt → suggestion - const r2 = service.getHint('rust-101', 'alice', { attemptCount: 1 }); - expect(r2.tier).toBe(HintDifficultyTier.Suggestion); - - // 2 attempts → solution - const r3 = service.getHint('rust-101', 'alice', { attemptCount: 2 }); - expect(r3.tier).toBe(HintDifficultyTier.Solution); - - // View counts are tracked per hint - expect(r1.viewCount).toBe(1); - expect(r2.viewCount).toBe(1); - expect(r3.viewCount).toBe(1); - }); - - it('different users have isolated view tracking', () => { - setupAccessibleUser('alice', 'ch-1', 'course-1'); - setupAccessibleUser('bob', 'ch-1', 'course-1'); - addAllTierHints('ch-1'); - service.setCooldownMs(0); - - service.getHint('ch-1', 'alice', { attemptCount: 0 }); - service.getHint('ch-1', 'alice', { attemptCount: 0 }); - service.getHint('ch-1', 'bob', { attemptCount: 0 }); - - // Alice has viewed the nudge hint 2 times, Bob 1 time - const hints = service.getHintsForChallenge('ch-1'); - const nudgeHintId = hints.find( - (h) => h.tier === HintDifficultyTier.Nudge, - )!.id; - - expect(service.getViewRecord('alice', nudgeHintId)?.viewCount).toBe(2); - expect(service.getViewRecord('bob', nudgeHintId)?.viewCount).toBe(1); - }); - }); -}); diff --git a/BackendAcademy/src/hints/hint.service.ts b/BackendAcademy/src/hints/hint.service.ts deleted file mode 100644 index 4e1dcc9387..0000000000 --- a/BackendAcademy/src/hints/hint.service.ts +++ /dev/null @@ -1,377 +0,0 @@ -import { - BadRequestException, - ForbiddenException, - Injectable, - NotFoundException, -} from '@nestjs/common'; -import { - HintDefinition, - HintDifficultyTier, - HintResponse, - HintViewRecord, - TIER_ORDER, - TIER_UNLOCK_THRESHOLDS, -} from './interfaces/hint.interface'; - -/** Default cooldown between repeated views of the same hint (ms). */ -const DEFAULT_HINT_COOLDOWN_MS = 60_000; - -/** Default maximum views per hint per user. 0 = unlimited. */ -const DEFAULT_MAX_VIEWS = 5; - -/** Generic fallback content when no tier-specific hint exists. */ -const FALLBACK_HINT_CONTENT = - 'Take a step back and re-read the problem statement carefully. ' + - 'Break it into smaller parts and tackle each one independently.'; - -@Injectable() -export class HintService { - // ── Internal registries ──────────────────────────────────────────── - - /** - * Challenge access registry: `challengeId -> courseId`. - * A learner must be enrolled in the course that owns a challenge - * before hints for that challenge are accessible. - */ - private readonly challengeRegistry = new Map(); - - /** - * Hint definitions keyed by challengeId, ordered by tier then insertion. - */ - private readonly hintDefinitions = new Map(); - - /** - * User-course enrollments: `userId -> Set`. - */ - private readonly enrollments = new Map>(); - - /** - * Per-user per-hint view tracking: `userId:hintId -> HintViewRecord`. - */ - private readonly viewRecords = new Map(); - - /** - * Per-user per-challenge hint cooldown timestamps (ms since epoch). - * Keyed by `userId:challengeId`, value = earliest timestamp at which - * the user may request the next hint. - */ - private readonly cooldowns = new Map(); - - /** Configurable cooldown duration (ms). */ - private cooldownMs = DEFAULT_HINT_COOLDOWN_MS; - - /** Configurable max views per hint per user. */ - private maxViews = DEFAULT_MAX_VIEWS; - - // ── Challenge registration ───────────────────────────────────────── - - /** - * Register a challenge as belonging to a course. - */ - registerChallenge(challengeId: string, courseId: string): void { - this.normalizeId(challengeId, 'challengeId'); - this.normalizeId(courseId, 'courseId'); - this.challengeRegistry.set(challengeId, courseId); - } - - /** - * Returns the courseId that owns a challenge, or null if unregistered. - */ - getCourseForChallenge(challengeId: string): string | null { - return this.challengeRegistry.get(challengeId) ?? null; - } - - // ── Enrollment management ────────────────────────────────────────── - - /** - * Enroll a user in a course, granting them access to all challenges - * that belong to that course. - */ - enrollUser(userId: string, courseId: string): void { - const normalizedUserId = this.normalizeId(userId, 'userId'); - const normalizedCourseId = this.normalizeId(courseId, 'courseId'); - - let courseSet = this.enrollments.get(normalizedUserId); - if (!courseSet) { - courseSet = new Set(); - this.enrollments.set(normalizedUserId, courseSet); - } - courseSet.add(normalizedCourseId); - } - - /** - * Check whether a user is enrolled in a specific course. - */ - isEnrolled(userId: string, courseId: string): boolean { - return this.enrollments.get(userId)?.has(courseId) ?? false; - } - - // ── Hint definition management ───────────────────────────────────── - - /** - * Add a hint definition to the registry. - */ - addHint(hint: Omit & { id?: string }): HintDefinition { - const normalizedChallengeId = this.normalizeId(hint.challengeId, 'challengeId'); - const fullHint: HintDefinition = { - id: hint.id ?? crypto.randomUUID(), - challengeId: normalizedChallengeId, - tier: hint.tier, - content: hint.content, - label: hint.label, - }; - - let list = this.hintDefinitions.get(normalizedChallengeId); - if (!list) { - list = []; - this.hintDefinitions.set(normalizedChallengeId, list); - } - list.push(fullHint); - return fullHint; - } - - /** - * Get all hint definitions for a challenge. - */ - getHintsForChallenge(challengeId: string): HintDefinition[] { - return this.hintDefinitions.get(challengeId) ?? []; - } - - // ── Configuration ────────────────────────────────────────────────── - - setCooldownMs(ms: number): void { - if (!Number.isFinite(ms) || ms < 0) { - throw new BadRequestException({ - error: 'INVALID_COOLDOWN', - message: 'cooldownMs must be a non-negative number', - }); - } - this.cooldownMs = ms; - } - - getCooldownMs(): number { - return this.cooldownMs; - } - - setMaxViews(max: number): void { - if (!Number.isInteger(max) || max < 0) { - throw new BadRequestException({ - error: 'INVALID_MAX_VIEWS', - message: 'maxViews must be a non-negative integer', - }); - } - this.maxViews = max; - } - - getMaxViews(): number { - return this.maxViews; - } - - // ── Core hint retrieval (enforcement point) ──────────────────────── - - /** - * Retrieve a hint for a user on a specific challenge. - * - * Enforcement checks (in order): - * 1. Challenge must be registered. - * 2. User must be enrolled in the owning course. - * 3. Requested tier (or best available) must be unlocked by attempt count. - * 4. Cooldown must have elapsed since the last hint for this challenge. - * 5. View limit must not have been exceeded for the selected hint. - * - * Fallback behaviour: - * - If no hint exists at the exact requested tier, the highest available - * tier at or below the requested tier is returned. - * - If no hints exist at all for the challenge, a generic fallback - * hint is returned with `isFallback: true`. - */ - getHint( - challengeId: string, - userId: string, - options: { - tier?: HintDifficultyTier; - /** Current attempt count for this user+challenge. */ - attemptCount: number; - }, - ): HintResponse { - const normalizedChallengeId = this.normalizeId(challengeId, 'challengeId'); - const normalizedUserId = this.normalizeId(userId, 'userId'); - - // ── 1. Challenge must exist ── - const courseId = this.challengeRegistry.get(normalizedChallengeId); - if (!courseId) { - throw new NotFoundException({ - error: 'HINT_CHALLENGE_NOT_FOUND', - message: `Challenge "${normalizedChallengeId}" is not registered`, - }); - } - - // ── 2. User must be enrolled ── - if (!this.isEnrolled(normalizedUserId, courseId)) { - throw new ForbiddenException({ - error: 'HINT_CHALLENGE_INACCESSIBLE', - message: - `User "${normalizedUserId}" is not enrolled in course "${courseId}" ` + - `that owns challenge "${normalizedChallengeId}"`, - }); - } - - // ── 3. Determine the unlocked tier ceiling ── - const unlockedTier = this.getUnlockedTier(options.attemptCount); - - // ── 4. Cooldown check ── - const cooldownKey = `${normalizedUserId}:${normalizedChallengeId}`; - const now = Date.now(); - const cooldownUntil = this.cooldowns.get(cooldownKey) ?? 0; - if (now < cooldownUntil) { - throw new BadRequestException({ - error: 'HINT_COOLDOWN_ACTIVE', - message: 'Please wait before requesting another hint', - nextAvailableAt: cooldownUntil, - }); - } - - // ── 5. Select best available hint ── - const allHints = this.hintDefinitions.get(normalizedChallengeId) ?? []; - const requestedTier = options.tier ?? unlockedTier; - - const selectedHint = this.selectHint(allHints, requestedTier, unlockedTier); - - // ── 6. Fallback: no hints at all ── - if (!selectedHint) { - // Set cooldown so the user can't spam fallback requests - this.cooldowns.set(cooldownKey, now + this.cooldownMs); - return { - hintId: 'fallback', - challengeId: normalizedChallengeId, - tier: HintDifficultyTier.Nudge, - content: FALLBACK_HINT_CONTENT, - label: 'General guidance', - viewCount: 0, - maxViews: 0, - nextAvailableAt: now + this.cooldownMs, - isFallback: true, - }; - } - - // ── 7. View tracking / anti-spoiler ── - const viewKey = `${normalizedUserId}:${selectedHint.id}`; - const record = this.viewRecords.get(viewKey) ?? { viewCount: 0, lastViewedAt: 0 }; - - if (this.maxViews > 0 && record.viewCount >= this.maxViews) { - throw new BadRequestException({ - error: 'HINT_MAX_VIEWS_EXCEEDED', - message: `You have reached the maximum views (${this.maxViews}) for this hint`, - hintId: selectedHint.id, - viewCount: record.viewCount, - }); - } - - // Record the view - record.viewCount += 1; - record.lastViewedAt = now; - this.viewRecords.set(viewKey, record); - - // Set cooldown - this.cooldowns.set(cooldownKey, now + this.cooldownMs); - - return { - hintId: selectedHint.id, - challengeId: normalizedChallengeId, - tier: selectedHint.tier, - content: selectedHint.content, - label: selectedHint.label, - viewCount: record.viewCount, - maxViews: this.maxViews, - nextAvailableAt: now + this.cooldownMs, - isFallback: false, - }; - } - - // ── Read-only query helpers ──────────────────────────────────────── - - /** - * Get the view record for a specific user+hint pair. - */ - getViewRecord(userId: string, hintId: string): HintViewRecord | undefined { - return this.viewRecords.get(`${userId}:${hintId}`); - } - - /** - * Get the highest tier a user may access given their attempt count. - */ - getUnlockedTier(attemptCount: number): HintDifficultyTier { - let unlocked = HintDifficultyTier.Nudge; - for (const tier of TIER_ORDER) { - if (attemptCount >= TIER_UNLOCK_THRESHOLDS[tier]) { - unlocked = tier; - } - } - return unlocked; - } - - /** - * Reset all state (useful in tests). - */ - resetAll(): void { - this.challengeRegistry.clear(); - this.hintDefinitions.clear(); - this.enrollments.clear(); - this.viewRecords.clear(); - this.cooldowns.clear(); - } - - // ── Internal helpers ─────────────────────────────────────────────── - - /** - * Select the best hint given a requested tier and the user's unlocked ceiling. - * - * Returns the highest-tier hint that is: - * 1. At or below the requested tier, AND - * 2. At or below the unlocked tier. - * - * Returns null if no hints match (caller should use fallback). - */ - private selectHint( - hints: HintDefinition[], - requestedTier: HintDifficultyTier, - unlockedTier: HintDifficultyTier, - ): HintDefinition | null { - const effectiveMax = this.minTier(requestedTier, unlockedTier); - - // Filter to hints at or below the effective ceiling, pick highest tier - const candidates = hints - .filter((h) => this.tierRank(h.tier) <= this.tierRank(effectiveMax)) - .sort((a, b) => this.tierRank(b.tier) - this.tierRank(a.tier)); - - return candidates[0] ?? null; - } - - /** - * Return the "lower" of two tiers (the one with the smaller rank). - */ - private minTier(a: HintDifficultyTier, b: HintDifficultyTier): HintDifficultyTier { - return this.tierRank(a) <= this.tierRank(b) ? a : b; - } - - /** - * Numeric rank of a tier (lower = less revealing). - */ - private tierRank(tier: HintDifficultyTier): number { - return TIER_ORDER.indexOf(tier); - } - - /** - * Normalize and validate a string ID. - */ - private normalizeId(value: string | undefined, field: string): string { - const normalized = value?.trim(); - if (!normalized) { - throw new BadRequestException({ - error: 'INVALID_HINT_INPUT', - message: `${field} is required`, - }); - } - return normalized; - } -} diff --git a/BackendAcademy/src/hints/index.ts b/BackendAcademy/src/hints/index.ts deleted file mode 100644 index b108f350fb..0000000000 --- a/BackendAcademy/src/hints/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -export { HintController } from './hint.controller'; -export { HintsModule } from './hint.module'; -export { HintService } from './hint.service'; -export { GetHintDto } from './dto/get-hint.dto'; -export { - HintDifficultyTier, - TIER_ORDER, - TIER_UNLOCK_THRESHOLDS, -} from './interfaces/hint.interface'; -export type { - HintDefinition, - HintResponse, - HintViewRecord, -} from './interfaces/hint.interface'; diff --git a/BackendAcademy/src/hints/interfaces/hint.interface.ts b/BackendAcademy/src/hints/interfaces/hint.interface.ts deleted file mode 100644 index ae050b0545..0000000000 --- a/BackendAcademy/src/hints/interfaces/hint.interface.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Difficulty tiers for hints, ordered from least to most revealing. - * - * Tier unlocking is gated by attempt count: - * - nudge: 0+ attempts (always available) - * - suggestion: 1+ attempts - * - solution: 2+ attempts - */ -export enum HintDifficultyTier { - Nudge = 'nudge', - Suggestion = 'suggestion', - Solution = 'solution', -} - -/** Ordered ranking – lower index = less revealing. */ -export const TIER_ORDER: HintDifficultyTier[] = [ - HintDifficultyTier.Nudge, - HintDifficultyTier.Suggestion, - HintDifficultyTier.Solution, -]; - -/** - * A static hint definition stored in the registry. - */ -export interface HintDefinition { - id: string; - challengeId: string; - tier: HintDifficultyTier; - content: string; - /** Optional label shown alongside the tier for clarity (e.g. "Gentle nudge"). */ - label?: string; -} - -/** - * The response payload returned to the learner. - */ -export interface HintResponse { - hintId: string; - challengeId: string; - tier: HintDifficultyTier; - content: string; - label?: string; - /** Number of times this user has viewed this specific hint. */ - viewCount: number; - /** Maximum views allowed per user per hint. 0 = no cap. */ - maxViews: number; - /** Unix-ms timestamp of the next moment the user may request a new hint. 0 = no cooldown. */ - nextAvailableAt: number; - /** Whether this response is a generic fallback (no tier-specific hint existed). */ - isFallback: boolean; -} - -/** - * Record of how many times a user has viewed a specific hint and when. - */ -export interface HintViewRecord { - viewCount: number; - lastViewedAt: number; -} - -/** - * Tier unlocking thresholds keyed by tier. - * Value = minimum number of attempts required to unlock that tier. - */ -export const TIER_UNLOCK_THRESHOLDS: Record = { - [HintDifficultyTier.Nudge]: 0, - [HintDifficultyTier.Suggestion]: 1, - [HintDifficultyTier.Solution]: 2, -}; diff --git a/BackendAcademy/src/i18n/i18n.module.ts b/BackendAcademy/src/i18n/i18n.module.ts deleted file mode 100644 index 6a5b6e62f5..0000000000 --- a/BackendAcademy/src/i18n/i18n.module.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Global, Module } from '@nestjs/common'; -import { LocalizationService } from './localization.service'; - -@Global() -@Module({ - providers: [LocalizationService], - exports: [LocalizationService], -}) -export class I18nModule {} diff --git a/BackendAcademy/src/i18n/localization.service.ts b/BackendAcademy/src/i18n/localization.service.ts deleted file mode 100644 index 00e3318499..0000000000 --- a/BackendAcademy/src/i18n/localization.service.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; - -/** - * Localized string keys for admin and learner flows. - */ -export interface LocalizationStrings { - // ── Admin dashboard ────────────────────────────────────── - 'admin.dashboard.title': string; - 'admin.dashboard.totalUsers': string; - 'admin.dashboard.activeTutors': string; - 'admin.dashboard.totalCourses': string; - 'admin.dashboard.completionRate': string; - - // ── Admin analytics ────────────────────────────────────── - 'admin.analytics.overview': string; - 'admin.analytics.export': string; - 'admin.analytics.exportDescription': string; - - // ── Learner onboarding ────────────────────────────────── - 'onboarding.welcome': string; - 'onboarding.step.profile': string; - 'onboarding.step.wallet': string; - 'onboarding.step.firstLesson': string; - 'onboarding.complete': string; - 'onboarding.inProgress': string; - - // ── Learner dashboard ─────────────────────────────────── - 'learner.dashboard.title': string; - 'learner.dashboard.streak': string; - 'learner.dashboard.xpEarned': string; - 'learner.dashboard.coursesInProgress': string; - - // ── Notifications ─────────────────────────────────────── - 'notification.welcome': string; - 'notification.milestone': string; - 'notification.submissionGraded': string; - 'notification.courseCompleted': string; - 'notification.certificateGenerated': string; - 'notification.certificateRevoked': string; - 'notification.submissionFlagged': string; - 'notification.reviewAssigned': string; - 'notification.reviewResolved': string; - 'notification.reportTriaged': string; - 'notification.reportEscalated': string; - 'notification.reportResolved': string; - 'notification.contentFlagged': string; - 'notification.contentApproved': string; - 'notification.contentRejected': string; - - // ── General ───────────────────────────────────────────── - 'general.loading': string; - 'general.error': string; - 'general.success': string; - 'general.noResults': string; - - // ── API info ──────────────────────────────────────────── - 'api.name': string; - 'api.status': string; -} - -const DEFAULT_LOCALE = 'en'; - -const STRINGS: Record = { - en: { - 'admin.dashboard.title': 'Admin Dashboard', - 'admin.dashboard.totalUsers': 'Total Users', - 'admin.dashboard.activeTutors': 'Active Tutors', - 'admin.dashboard.totalCourses': 'Total Courses', - 'admin.dashboard.completionRate': 'Completion Rate', - - 'admin.analytics.overview': 'Analytics Overview', - 'admin.analytics.export': 'Export Analytics', - 'admin.analytics.exportDescription': 'Download analytics data as CSV', - - 'onboarding.welcome': 'Welcome to RustAcademy!', - 'onboarding.step.profile': 'Create your profile', - 'onboarding.step.wallet': 'Connect your wallet', - 'onboarding.step.firstLesson': 'Complete your first lesson', - 'onboarding.complete': 'Onboarding complete!', - 'onboarding.inProgress': 'Onboarding in progress', - - 'learner.dashboard.title': 'My Dashboard', - 'learner.dashboard.streak': 'Current Streak', - 'learner.dashboard.xpEarned': 'XP Earned', - 'learner.dashboard.coursesInProgress': 'Courses In Progress', - - 'notification.welcome': 'Welcome to RustAcademy! Start your first lesson today.', - 'notification.milestone': 'Congratulations on reaching a new milestone!', - 'notification.submissionGraded': 'Your submission has been graded.', - 'notification.courseCompleted': 'You have completed the course!', - 'notification.certificateGenerated': 'Your course completion certificate has been generated.', - 'notification.certificateRevoked': 'A course completion certificate has been revoked.', - 'notification.submissionFlagged': 'Your submission has been flagged for review.', - 'notification.reviewAssigned': 'A review has been assigned to you.', - 'notification.reviewResolved': 'Your review has been resolved.', - 'notification.reportTriaged': 'Your report has been triaged.', - 'notification.reportEscalated': 'Your report has been escalated.', - 'notification.reportResolved': 'Your report has been resolved.', - 'notification.contentFlagged': 'Your content has been flagged for moderation review.', - 'notification.contentApproved': 'Your content has been approved by moderation.', - 'notification.contentRejected': 'Your content has been rejected by moderation.', - - 'general.loading': 'Loading...', - 'general.error': 'An error occurred', - 'general.success': 'Operation completed successfully', - 'general.noResults': 'No results found', - - 'api.name': 'RustAcademy API', - 'api.status': 'ok', - }, -}; - -@Injectable() -export class LocalizationService { - private readonly logger = new Logger(LocalizationService.name); - private locale: string; - - constructor(private readonly configService: ConfigService) { - this.locale = this.configService.get('LOCALE') || DEFAULT_LOCALE; - this.logger.log(`LocalizationService initialized with locale: ${this.locale}`); - } - - /** - * Translates a key into the current locale. - * Falls back to 'en' if the locale or key is not found. - */ - t(key: keyof LocalizationStrings): string { - const localeStrings = STRINGS[this.locale] ?? STRINGS[DEFAULT_LOCALE]; - return localeStrings[key] ?? `[missing: ${key}]`; - } - - /** - * Sets the current locale at runtime. - */ - setLocale(locale: string): void { - if (STRINGS[locale]) { - this.locale = locale; - this.logger.log(`Locale changed to: ${locale}`); - } else { - this.logger.warn(`Locale '${locale}' not found, falling back to '${DEFAULT_LOCALE}'`); - this.locale = DEFAULT_LOCALE; - } - } - - /** - * Returns the current locale. - */ - getLocale(): string { - return this.locale; - } - - /** - * Returns all available locales. - */ - getAvailableLocales(): string[] { - return Object.keys(STRINGS); - } -} diff --git a/BackendAcademy/src/integration/learner-journey.spec.ts b/BackendAcademy/src/integration/learner-journey.spec.ts deleted file mode 100644 index 2eff5ee9dd..0000000000 --- a/BackendAcademy/src/integration/learner-journey.spec.ts +++ /dev/null @@ -1,862 +0,0 @@ -/** - * Integration tests — end-to-end learner journeys (#452, #643 / BA-075) - * - * Covers the critical path from enrollment through submission, review, - * progress, reward, and certificate without mocking every layer. Uses real - * NestJS DI with the full service graph; only external infrastructure - * (TypeORM repositories, telemetry, certificate issuance) is replaced with - * in-memory fixtures so the suite runs in CI with isolated state. - * - * Regression paths required by BA-075: - * - Happy path — Journeys 1–5 and 7 (auth, onboarding, enrollment, - * submission, review, rewards, certificate). - * - Retry path — rejected submissions can be resubmitted; challenge - * attempt limits are enforced (Journey 6). - * - Unauthorized path — JWT role guards reject missing/invalid/learner - * tokens on tutor-only endpoints (Journey 6). - * - Partial-failure — mixed review outcomes keep queue/stats consistent - * (Journey 6). - * - * Run: npx jest --config jest.config.ts --testPathPattern='integration/learner-journey' - */ - -import { Test, TestingModule } from '@nestjs/testing'; -import { - BadRequestException, - ConflictException, - ForbiddenException, - NotFoundException, - UnauthorizedException, - ExecutionContext, -} from '@nestjs/common'; -import { getRepositoryToken } from '@nestjs/typeorm'; - -// ── Auth ── -import { JwtService } from '@nestjs/jwt'; -import { UserRole } from '../auth/enums/user-role.enum'; -import { JwtTutorGuard } from '../auth/guards/jwt-tutor.guard'; - -// ── Onboarding ── -import { OnboardingService } from '../onboarding/onboarding.service'; - -// ── Courses & Lessons ── -import { CourseService } from '../courses/course.service'; -import { CourseEntity } from '../courses/course.entity'; -import { CourseRevisionEntity } from '../courses/course-revision.entity'; -import { CertificateService } from '../courses/certificate.service'; -import { CourseLevel } from '../courses/interfaces/course-level.enum'; -import { LessonService } from '../lessons/lesson.service'; - -// ── Tasks & Submissions ── -import { TaskService } from '../tasks/task.service'; -import { TaskDifficulty } from '../tasks/interfaces/task-difficulty.enum'; -import { SubmissionService } from '../submissions/submission.service'; -import { TutorReviewService } from '../submissions/tutor-review.service'; -import { SubmissionStatus } from '../submissions/interfaces/submission-status.enum'; - -// ── Rewards & Streaks ── -import { RewardsService } from '../rewards/rewards.service'; -import { StreakService } from '../rewards/streak.service'; -import { DatabaseService } from '../database/database.service'; - -// ── Badges ── -import { BadgesService } from '../badges/badges.service'; - -// ── Social ── -import { SocialService } from '../social/social.service'; - -// ── Challenges ── -import { ChallengesService } from '../challenges/challenges.service'; - -// ── Monitoring ── -import { MonitoringService } from '../monitoring/monitoring.service'; -import { TransactionManagerService } from '../common/transaction-manager.service'; - -/** - * Minimal in-memory TypeORM-like repository used to isolate persistence - * per suite. `create` instantiates the real entity class so default values - * (e.g. `isActive`, `version`) behave like production. - */ -function createInMemoryRepo( - Entity?: new (partial: Partial) => T, -) { - const store = new Map(); - - function matches(row: T, where?: Record): boolean { - if (!where) return true; - return Object.entries(where).every( - ([key, value]) => (row as Record)[key] === value, - ); - } - - function query(opts?: { - where?: Record; - order?: Record; - }): T[] { - let rows = Array.from(store.values()).filter((r) => matches(r, opts?.where)); - if (opts?.order) { - const [key, direction] = Object.entries(opts.order)[0]; - rows = [...rows].sort((a, b) => { - const av = (a as Record)[key] as number; - const bv = (b as Record)[key] as number; - const cmp = av < bv ? -1 : av > bv ? 1 : 0; - return direction === 'DESC' ? -cmp : cmp; - }); - } - return rows; - } - - return { - store, - create: (partial: Partial): T => - Entity ? new Entity(partial) : ({ ...partial } as T), - save: async (entity: T): Promise => { - store.set(entity.id, entity); - return entity; - }, - find: async (opts?: { - where?: Record; - order?: Record; - }): Promise => query(opts), - findOne: async (opts?: { - where?: Record; - order?: Record; - }): Promise => query(opts)[0] ?? null, - remove: async (entity: T): Promise => { - store.delete(entity.id); - return entity; - }, - count: async (opts?: { where?: Record }): Promise => - query(opts).length, - }; -} - -/** Build a fake HTTP context for guard tests. */ -function mockHttpContext(headers: Record = {}): ExecutionContext { - return { - switchToHttp: () => ({ - getRequest: () => ({ headers }), - }), - } as unknown as ExecutionContext; -} - -describe('End-to-End Learner Journey', () => { - let onboarding: OnboardingService; - let courses: CourseService; - let lessons: LessonService; - let tasks: TaskService; - let submissions: SubmissionService; - let tutorReview: TutorReviewService; - let rewards: RewardsService; - let streaks: StreakService; - let badges: BadgesService; - let social: SocialService; - let challenges: ChallengesService; - let jwt: JwtService; - - // ───────────────────────────────────────────────────────────────── - // Test fixtures - // ───────────────────────────────────────────────────────────────── - const LEARNER_ID = 'learner-alice-42'; - const TUTOR_ID = 'tutor-bob-99'; - - const courseRepo = createInMemoryRepo(CourseEntity); - const revisionRepo = createInMemoryRepo( - CourseRevisionEntity, - ); - - // Plain functions (not jest.fn) so `resetMocks` in the jest config does - // not wipe their implementations between tests. - const certificateStub = { - generateCertificate: async (params: { - userId: string; - courseId: string; - courseTitle: string; - xpAwarded: number; - }) => ({ - id: `cert-${params.courseId}`, - userId: params.userId, - courseId: params.courseId, - courseTitle: params.courseTitle, - issuedAt: new Date(), - verificationCode: `vc-${params.courseId}`, - shareableUrl: `https://rustacademy.xyz/certificates/verify/vc-${params.courseId}`, - xpAwarded: params.xpAwarded, - completionDate: new Date().toISOString(), - status: 'active', - }), - revokeCertificate: () => null, - }; - - beforeAll(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - OnboardingService, - CourseService, - LessonService, - TaskService, - SubmissionService, - RewardsService, - StreakService, - BadgesService, - SocialService, - ChallengesService, - TutorReviewService, - TransactionManagerService, - { - provide: getRepositoryToken(CourseEntity), - useValue: courseRepo, - }, - { - provide: getRepositoryToken(CourseRevisionEntity), - useValue: revisionRepo, - }, - { - provide: CertificateService, - useValue: certificateStub, - }, - { - // Telemetry is out of scope for these journeys. - provide: MonitoringService, - useValue: { - recordDomainEvent: jest.fn(), - recordError: jest.fn(), - recordHttpRequest: jest.fn(), - }, - }, - { - // No persistence-backed coupon flows in these journeys. - provide: DatabaseService, - useValue: {}, - }, - { - provide: JwtService, - useValue: { - sign: (payload: Record) => - `mock-jwt-${JSON.stringify(payload)}`, - verify: () => true, - // Plain async function: `resetMocks` would strip a jest.fn - // implementation between tests. - verifyAsync: async (token: string) => { - if (token.startsWith('mock-jwt-')) { - return JSON.parse(token.slice('mock-jwt-'.length)); - } - throw new Error('invalid token'); - }, - }, - }, - ], - }).compile(); - - onboarding = module.get(OnboardingService); - courses = module.get(CourseService); - lessons = module.get(LessonService); - tasks = module.get(TaskService); - submissions = module.get(SubmissionService); - tutorReview = module.get(TutorReviewService); - rewards = module.get(RewardsService); - streaks = module.get(StreakService); - badges = module.get(BadgesService); - social = module.get(SocialService); - challenges = module.get(ChallengesService); - jwt = module.get(JwtService); - }); - - beforeEach(() => { - // Isolate shared in-memory state across journeys (#451). Course state is - // intentionally NOT cleared here: Journey 2 builds a course catalog across - // sequential tests, and Journey 7 provisions its own fixtures. - rewards.clearAll(); - streaks.clearAll(); - badges.clearAll(); - challenges.resetVotes(); - challenges.resetAttempts(); - }); - - // ═════════════════════════════════════════════════════════════════ - // Journey 1 — Authentication & Onboarding - // ═════════════════════════════════════════════════════════════════ - - describe('Journey 1: Authentication & Onboarding', () => { - it('can issue JWTs for different user roles', () => { - const learnerToken = jwt.sign({ sub: LEARNER_ID, role: UserRole.LEARNER }); - const tutorToken = jwt.sign({ sub: TUTOR_ID, role: UserRole.TUTOR }); - const adminToken = jwt.sign({ sub: 'admin-1', role: UserRole.ADMIN }); - - expect(learnerToken).toContain('mock-jwt-'); - expect(tutorToken).not.toBe(learnerToken); - expect(adminToken).not.toBe(learnerToken); - }); - - it('completes the full onboarding flow', async () => { - // 1. Create onboarding progress - const progress = await onboarding.create({ - userId: LEARNER_ID, - currentStep: 'welcome', - totalSteps: 4, - }); - - expect(progress.userId).toBe(LEARNER_ID); - expect(progress.isComplete).toBe(false); - expect(progress.completedSteps).toEqual([]); - - // 2. Complete steps sequentially - const steps = ['welcome', 'profile-setup', 'wallet-connect', 'first-task']; - for (const step of steps) { - const updated = await onboarding.completeStep(progress.id, step); - expect(updated.completedSteps).toContain(step); - expect(updated.currentStep).toBe(step); - } - - // 3. Verify onboarding is marked complete - const final = await onboarding.findByUserId(LEARNER_ID); - expect(final).not.toBeNull(); - expect(final!.isComplete).toBe(true); - expect(final!.completedAt).toBeInstanceOf(Date); - expect(final!.completedSteps).toHaveLength(4); - }); - }); - - // ═════════════════════════════════════════════════════════════════ - // Journey 2 — Course Enrollment & Lesson Access - // ═════════════════════════════════════════════════════════════════ - - describe('Journey 2: Course Enrollment & Lessons', () => { - let courseId: string; - - it('creates a course and returns it in the catalog', async () => { - const course = await courses.create({ - title: 'Rust Fundamentals', - description: 'Learn the basics of Rust — ownership, borrowing, and lifetimes.', - level: CourseLevel.BEGINNER, - order: 1, - learningPathId: 'rust-path-1', - duration: 40, - prerequisites: [], - skills: ['rust', 'memory-management'], - xpReward: 500, - }); - - expect(course.id).toBeDefined(); - expect(course.title).toBe('Rust Fundamentals'); - expect(course.level).toBe(CourseLevel.BEGINNER); - expect(course.isActive).toBe(true); - courseId = course.id; - }); - - it('lists all active courses', async () => { - const all = await courses.findAll(); - expect(all.length).toBeGreaterThanOrEqual(1); - expect(all.find((c) => c.id === courseId)).toBeDefined(); - }); - - it('creates lessons for the course in order', async () => { - const lesson1 = await lessons.create({ - courseId, - title: 'Introduction to Ownership', - content: '## Ownership in Rust\n\nOwnership is Rust\'s most unique feature...', - order: 1, - duration: 15, - xpReward: 50, - prerequisites: [], - }); - - const lesson2 = await lessons.create({ - courseId, - title: 'References and Borrowing', - content: '## Borrowing rules...', - order: 2, - duration: 20, - xpReward: 60, - prerequisites: ['Introduction to Ownership'], - }); - - expect(lesson1.id).toBeDefined(); - expect(lesson2.order).toBe(2); - - // Lessons should be retrievable by course - const courseLessons = await lessons.findByCourseId(courseId); - expect(courseLessons).toHaveLength(2); - expect(courseLessons[0].order).toBe(1); - expect(courseLessons[1].order).toBe(2); - }); - }); - - // ═════════════════════════════════════════════════════════════════ - // Journey 3 — Task Submission & Grading - // ═════════════════════════════════════════════════════════════════ - - describe('Journey 3: Task Submission & Grading', () => { - let taskId: string; - - it('creates a coding task for a lesson', async () => { - const task = await tasks.create({ - lessonId: 'lesson-ownership-1', - title: 'Implement a Safe Transfer', - description: 'Write a function that safely transfers ownership of a value.', - difficulty: TaskDifficulty.MEDIUM, - testCases: ['test_transfer()', 'test_drop()'], - expectedOutput: 'All tests passed', - xpReward: 100, - passingScore: 70, - templateCode: 'fn transfer(value: T) -> T { /* your code */ }', - }); - - expect(task.id).toBeDefined(); - expect(task.difficulty).toBe(TaskDifficulty.MEDIUM); - expect(task.xpReward).toBe(100); - taskId = task.id; - }); - - it('learner submits a solution', async () => { - const submission = await submissions.create({ - taskId, - userId: LEARNER_ID, - content: 'fn transfer(value: T) -> T { value }', - }); - - expect(submission.id).toBeDefined(); - expect(submission.status).toBe(SubmissionStatus.PENDING); - expect(submission.userId).toBe(LEARNER_ID); - expect(submission.taskId).toBe(taskId); - expect(submission.submittedAt).toBeInstanceOf(Date); - }); - - it('all submissions start in PENDING status', async () => { - const pending = await submissions.findByStatus(SubmissionStatus.PENDING); - expect(pending.length).toBeGreaterThanOrEqual(1); - }); - - it('tutor reviews and approves the submission', async () => { - const pending = await submissions.findByStatus(SubmissionStatus.PENDING); - expect(pending.length).toBeGreaterThan(0); - - const submissionId = pending[0].id; - const reviewed = await submissions.review( - submissionId, - TUTOR_ID, - SubmissionStatus.APPROVED, - 'Great work! Ownership transfer is implemented correctly.', - 95, - ); - - expect(reviewed.status).toBe(SubmissionStatus.APPROVED); - expect(reviewed.reviewedBy).toBe(TUTOR_ID); - expect(reviewed.feedback).toBe('Great work! Ownership transfer is implemented correctly.'); - expect(reviewed.score).toBe(95); - expect(reviewed.reviewedAt).toBeInstanceOf(Date); - }); - - it('learner can find their submissions', async () => { - const learnerSubs = await submissions.findByUserId(LEARNER_ID); - expect(learnerSubs.length).toBeGreaterThanOrEqual(1); - expect(learnerSubs.every((s) => s.userId === LEARNER_ID)).toBe(true); - }); - - it('tutor can reject a submission with feedback', async () => { - // Submit another solution - const sub = await submissions.create({ - taskId, - userId: 'learner-charlie', - content: '// incomplete solution', - }); - - const reviewed = await submissions.review( - sub.id, - TUTOR_ID, - SubmissionStatus.REJECTED, - 'This solution does not handle edge cases. Please revise.', - 40, - ); - - expect(reviewed.status).toBe(SubmissionStatus.REJECTED); - expect(reviewed.score).toBe(40); - }); - }); - - // ═════════════════════════════════════════════════════════════════ - // Journey 4 — Rewards, Streaks & Badges - // ═════════════════════════════════════════════════════════════════ - - describe('Journey 4: Rewards, Streaks & Badges', () => { - it('awards XP to a learner for completing a task', () => { - const prog = rewards.addXp(LEARNER_ID, 100); - expect(prog.xp).toBe(100); - expect(prog.level).toBe(2); // 100 XP = level 2 - }); - - it('tracks progressive XP gain across multiple submissions', () => { - rewards.addXp(LEARNER_ID, 100); - rewards.addXp(LEARNER_ID, 300); // Total: 400 → level 3 - const prog = rewards.getUserProgression(LEARNER_ID); - expect(prog.xp).toBe(400); - expect(prog.level).toBe(3); - expect(prog.xpToNextLevel).toBeGreaterThan(0); - }); - - it('daily check-in builds a streak', () => { - const checkin = streaks.checkIn(LEARNER_ID); - expect(checkin.newStreak).toBe(1); - expect(checkin.xpAwarded).toBeGreaterThan(0); - expect(checkin.message).toContain('Welcome'); - }); - - it('prevents double check-in on the same day', () => { - streaks.checkIn(LEARNER_ID); - expect(() => streaks.checkIn(LEARNER_ID)).toThrow(/already checked in today/); - }); - - it('awards a badge for first login', () => { - const result = badges.awardBadge(LEARNER_ID, 'first-login', '0xnft-first-login'); - expect(result.badges).toHaveLength(1); - expect(result.badges[0].badge.id).toBe('first-login'); - expect(result.badges[0].badge.name).toBe('First Steps'); - expect(result.badges[0].nftTokenId).toBe('0xnft-first-login'); - }); - - it('prevents duplicate badge awards', () => { - badges.awardBadge(LEARNER_ID, 'first-login', '0xnft-first-login'); - const result = badges.awardBadge(LEARNER_ID, 'first-login', '0xnft-another'); - expect(result.badges).toHaveLength(1); // Still only one - }); - - it('throws for unknown badge id', () => { - expect(() => - badges.awardBadge(LEARNER_ID, 'non-existent-badge', '0xnft'), - ).toThrow(NotFoundException); - }); - - it('returns the leaderboard with correct ordering', () => { - // Seed multiple learners with different XP - rewards.addXp('learner-a', 200); - rewards.addXp('learner-b', 600); - rewards.addXp('learner-c', 300); - - const { leaderboard, totalParticipants } = rewards.getLeaderboard(5); - expect(totalParticipants).toBe(3); - expect(leaderboard[0].userId).toBe('learner-b'); // 600 XP - expect(leaderboard[0].rank).toBe(1); - }); - }); - - // ═════════════════════════════════════════════════════════════════ - // Journey 5 — Cross-cutting: Social, Challenges & XP - // ═════════════════════════════════════════════════════════════════ - - describe('Journey 5: Social & Challenge Voting', () => { - it('creates a post and retrieves it after moderation', () => { - const post = social.createPost(LEARNER_ID, { - content: 'Just completed my first Rust course! #rust #achievement', - }); - - expect(post.id).toMatch(/^post_/); - expect(post.moderationStatus).toBe('pending'); - - // Post should not appear in feed yet (pending) - const feedBefore = social.getFeed({}); - expect(feedBefore.posts.find((p) => p.id === post.id)).toBeUndefined(); - - // Approve the post - social.moderatePost(post.id, 'moderator-1', { status: 'approved' }); - - // Now it should appear - const feedAfter = social.getFeed({}); - const found = feedAfter.posts.find((p) => p.id === post.id); - expect(found).toBeDefined(); - expect(found!.moderationStatus).toBe('approved'); - }); - - it('casts and tallies challenge votes', () => { - const result = challenges.castVote('weekly-challenge-rust-1', { - userId: LEARNER_ID, - value: 'up', - }); - - expect(result.upvotes).toBe(1); - expect(result.downvotes).toBe(0); - expect(result.score).toBe(1); - expect(result.userVote).toBe('up'); - }); - - it('updates a vote when user changes their mind', () => { - challenges.castVote('weekly-challenge-rust-1', { - userId: LEARNER_ID, - value: 'down', - }); - - const tally = challenges.getTally('weekly-challenge-rust-1'); - expect(tally.upvotes).toBe(0); - expect(tally.downvotes).toBe(1); - expect(tally.score).toBe(-1); - }); - - it('follows and unfollows users', () => { - const follow = social.followUser(LEARNER_ID, TUTOR_ID); - expect(follow.followerId).toBe(LEARNER_ID); - expect(follow.followersCount).toBe(1); - - const unfollow = social.unfollowUser(LEARNER_ID, TUTOR_ID); - expect(unfollow.followersCount).toBe(0); - }); - - it('prevents self-follow', () => { - expect(() => social.followUser(LEARNER_ID, LEARNER_ID)).toThrow( - BadRequestException, - ); - }); - }); - - // ═════════════════════════════════════════════════════════════════ - // Journey 6 — Submission resilience (#643 / BA-075) - // ═════════════════════════════════════════════════════════════════ - - describe('Journey 6: Submission Resilience', () => { - // ── Retry path ───────────────────────────────────────────────── - - it('retry path — a rejected submission can be resubmitted and approved', async () => { - challenges.setMaxAttempts('challenge-retry-1', 3); - - const first = await submissions.create({ - taskId: 'challenge-retry-1', - userId: 'learner-retry', - content: 'fn main() { /* attempt 1 */ }', - }); - await submissions.review( - first.id, - TUTOR_ID, - SubmissionStatus.REJECTED, - 'Missing edge-case handling. Please revise and resubmit.', - 30, - ); - - // Learner revises and retries - const second = await submissions.create({ - taskId: 'challenge-retry-1', - userId: 'learner-retry', - content: 'fn main() { /* attempt 2 — fixed */ }', - }); - const reviewed = await submissions.review( - second.id, - TUTOR_ID, - SubmissionStatus.APPROVED, - 'Much better. Approved!', - 90, - ); - - expect(reviewed.status).toBe(SubmissionStatus.APPROVED); - - // Attempt accounting reflects exactly the two submissions - const info = challenges.getAttemptInfo('challenge-retry-1', 'learner-retry'); - expect(info.current).toBe(2); - expect(info.remaining).toBe(1); - }); - - it('retry path — draft can be revised and published after rejection', async () => { - challenges.setMaxAttempts('challenge-retry-draft', 3); - - const draft = await submissions.saveDraft({ - taskId: 'challenge-retry-draft', - userId: 'learner-draft', - content: '// work in progress', - }); - const published = await submissions.publishDraft(draft.id); - expect(published.status).toBe(SubmissionStatus.PENDING); - - await submissions.review( - published.id, - TUTOR_ID, - SubmissionStatus.NEEDS_REVISION, - 'Add a main function.', - 45, - ); - - // Learner saves a revised draft and publishes it again (retry) - const draft2 = await submissions.saveDraft({ - taskId: 'challenge-retry-draft', - userId: 'learner-draft', - content: 'fn main() { println!("ok"); }', - }); - const published2 = await submissions.publishDraft(draft2.id); - expect(published2.status).toBe(SubmissionStatus.PENDING); - - const reviewed = await submissions.review( - published2.id, - TUTOR_ID, - SubmissionStatus.APPROVED, - 'Approved after revision.', - 88, - ); - expect(reviewed.status).toBe(SubmissionStatus.APPROVED); - }); - - it('retry path — challenge attempt limit blocks further submissions', async () => { - challenges.setMaxAttempts('challenge-retry-2', 2); - - await submissions.create({ - taskId: 'challenge-retry-2', - userId: 'learner-blocked', - content: 'attempt 1', - }); - await submissions.create({ - taskId: 'challenge-retry-2', - userId: 'learner-blocked', - content: 'attempt 2', - }); - - let error: BadRequestException | undefined; - try { - await submissions.create({ - taskId: 'challenge-retry-2', - userId: 'learner-blocked', - content: 'attempt 3 — must be blocked', - }); - } catch (err) { - error = err as BadRequestException; - } - - expect(error).toBeInstanceOf(BadRequestException); - expect((error!.getResponse() as Record).error).toBe( - 'ATTEMPT_LIMIT_EXCEEDED', - ); - }); - - // ── Unauthorized path ────────────────────────────────────────── - - it('unauthorized path — missing token is rejected by the tutor guard', async () => { - const guard = new JwtTutorGuard(jwt); - await expect(guard.canActivate(mockHttpContext({}))).rejects.toThrow( - UnauthorizedException, - ); - }); - - it('unauthorized path — learner token is forbidden on tutor endpoints', async () => { - const learnerToken = jwt.sign({ sub: LEARNER_ID, role: UserRole.LEARNER }); - const guard = new JwtTutorGuard(jwt); - - await expect( - guard.canActivate(mockHttpContext({ authorization: `Bearer ${learnerToken}` })), - ).rejects.toThrow(ForbiddenException); - }); - - it('unauthorized path — invalid/expired token is rejected', async () => { - const guard = new JwtTutorGuard(jwt); - await expect( - guard.canActivate(mockHttpContext({ authorization: 'Bearer not-a-real-token' })), - ).rejects.toThrow(UnauthorizedException); - }); - - it('authorized path — valid tutor token passes the guard', async () => { - const tutorToken = jwt.sign({ sub: TUTOR_ID, role: UserRole.TUTOR }); - const guard = new JwtTutorGuard(jwt); - - await expect( - guard.canActivate(mockHttpContext({ authorization: `Bearer ${tutorToken}` })), - ).resolves.toBe(true); - }); - - // ── Partial-failure path ─────────────────────────────────────── - - it('partial-failure path — mixed review outcomes keep the queue consistent', async () => { - const subA = await submissions.create({ - taskId: 'task-partial-1', - userId: 'learner-p1', - content: 'solution A', - }); - const subB = await submissions.create({ - taskId: 'task-partial-1', - userId: 'learner-p2', - content: 'solution B', - }); - const subC = await submissions.create({ - taskId: 'task-partial-1', - userId: 'learner-p3', - content: 'solution C', - }); - - // Two of three submissions resolve; one stays pending - await tutorReview.reviewSubmission(subA.id, TUTOR_ID, { - status: SubmissionStatus.APPROVED, - feedback: 'Approved.', - score: 95, - }); - await tutorReview.reviewSubmission(subB.id, TUTOR_ID, { - status: SubmissionStatus.REJECTED, - feedback: 'Does not compile.', - score: 20, - }); - - const stats = await tutorReview.getStats('task-partial-1'); - expect(stats).toMatchObject({ - total: 3, - approved: 1, - rejected: 1, - pending: 1, - needs_revision: 0, - }); - - // The unresolved submission remains in the pending queue - const pendingQueue = await tutorReview.getPendingQueue({ taskId: 'task-partial-1' }); - expect(pendingQueue.items.map((s) => s.id)).toEqual([subC.id]); - - // Reviewer history contains the resolved submissions for this task - const history = await tutorReview.getReviewedByTutor(TUTOR_ID, {}); - const resolvedIds = history.items - .filter((s) => s.taskId === 'task-partial-1') - .map((s) => s.id) - .sort(); - expect(resolvedIds).toEqual([subA.id, subB.id].sort()); - }); - }); - - // ═════════════════════════════════════════════════════════════════ - // Journey 7 — Enrollment, Progress & Certificate (#357, #356) - // ═════════════════════════════════════════════════════════════════ - - describe('Journey 7: Enrollment, Completion & Certificate', () => { - let courseId: string; - - beforeEach(async () => { - const course = await courses.create({ - title: 'Smart Contracts 101', - description: 'Build safe Stellar smart contracts.', - level: CourseLevel.BEGINNER, - order: 2, - learningPathId: 'stellar-path-1', - duration: 60, - prerequisites: [], - skills: ['stellar', 'soroban'], - xpReward: 250, - }); - courseId = course.id; - }); - - it('enrolls a learner in a course', async () => { - const enrollment = await courses.enrollUser(LEARNER_ID, courseId); - expect(enrollment.status).toBe('active'); - expect(enrollment.userId).toBe(LEARNER_ID); - - const userEnrollments = courses.getUserEnrollments(LEARNER_ID); - expect(userEnrollments).toHaveLength(1); - }); - - it('prevents duplicate enrollment', async () => { - await courses.enrollUser(LEARNER_ID, courseId); - await expect(courses.enrollUser(LEARNER_ID, courseId)).rejects.toThrow( - ConflictException, - ); - }); - - it('completes a course: XP reward, progress and certificate', async () => { - const completion = await courses.completeCourse(courseId, LEARNER_ID); - - expect(completion.xpAwarded).toBe(250); - expect(completion.certificate.verificationCode).toBe(`vc-${courseId}`); - expect(completion.certificate.shareableUrl).toContain('/verify/'); - expect(completion.certificate.issuedAt).toBeInstanceOf(Date); - - // XP was actually recorded on the learner's progression - const progression = rewards.getUserProgression(LEARNER_ID); - expect(progression.xp).toBeGreaterThanOrEqual(250); - }); - }); -}); diff --git a/BackendAcademy/src/jobs/entities/grading-job.entity.ts b/BackendAcademy/src/jobs/entities/grading-job.entity.ts deleted file mode 100644 index d2935c1503..0000000000 --- a/BackendAcademy/src/jobs/entities/grading-job.entity.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm'; - -export enum GradingJobStatus { - PENDING = 'PENDING', - IN_PROGRESS = 'IN_PROGRESS', - FAILED = 'FAILED', - COMPLETED = 'COMPLETED', -} - -@Entity({ name: 'grading_jobs' }) -export class GradingJobEntity { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column('uuid') - submissionId: string; - - @Column({ type: 'jsonb', nullable: true }) - payload: any; - - @Column({ type: 'int', default: 0 }) - attempts: number; - - @Column({ type: 'int', default: 5 }) - maxAttempts: number; - - @Column({ type: 'text', nullable: true }) - lastError?: string | null; - - @Column({ type: 'timestamptz', nullable: true }) - nextRetryAt?: Date | null; - - @Column({ type: 'varchar', default: GradingJobStatus.PENDING }) - status: GradingJobStatus; - - @CreateDateColumn({ type: 'timestamptz' }) - createdAt: Date; - - @UpdateDateColumn({ type: 'timestamptz' }) - updatedAt: Date; -} diff --git a/BackendAcademy/src/jobs/grading-job-lease.util.ts b/BackendAcademy/src/jobs/grading-job-lease.util.ts deleted file mode 100644 index f96b9388ca..0000000000 --- a/BackendAcademy/src/jobs/grading-job-lease.util.ts +++ /dev/null @@ -1,30 +0,0 @@ -export type GradingJobStatus = 'PENDING' | 'LEASED' | 'DONE' | 'FAILED'; - -export interface GradingJobRecord { - jobId: string; - status: GradingJobStatus; - attempts: number; - leaseOwner?: string; - leaseExpiresAt?: Date; -} - -const LEASE_DURATION_MS = 60_000; - -/** Acquires a lease on a job for a worker, only if unleased or expired. */ -export function acquireLease( - job: GradingJobRecord, - workerId: string, - now: Date = new Date(), -): GradingJobRecord { - const leaseActive = job.leaseExpiresAt && job.leaseExpiresAt > now; - if (job.status === 'LEASED' && leaseActive && job.leaseOwner !== workerId) { - throw new Error(`Job ${job.jobId} is already leased by another worker`); - } - return { - ...job, - status: 'LEASED', - attempts: job.attempts + 1, - leaseOwner: workerId, - leaseExpiresAt: new Date(now.getTime() + LEASE_DURATION_MS), - }; -} diff --git a/BackendAcademy/src/jobs/grading-job.service.spec.ts b/BackendAcademy/src/jobs/grading-job.service.spec.ts deleted file mode 100644 index 8ae28d5e5a..0000000000 --- a/BackendAcademy/src/jobs/grading-job.service.spec.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { GradingJobService } from './grading-job.service'; -import { GradingJobEntity, GradingJobStatus } from './entities/grading-job.entity'; - -describe('GradingJobService retries', () => { - let service: GradingJobService; - let mockRepo: any; - let mockGradingResultService: any; - - beforeEach(() => { - mockRepo = { - create: jest.fn((v) => v), - save: jest.fn(async (v) => v), - find: jest.fn(async () => []), - }; - - let call = 0; - mockGradingResultService = { - saveResult: jest.fn(async () => { - call += 1; - if (call === 1) throw new Error('transient'); - return {}; - }), - }; - - service = new GradingJobService(mockRepo, mockGradingResultService); - }); - - it('retries a failed job and marks completed on success', async () => { - const job: GradingJobEntity = { - id: '1', - submissionId: 'sub-1', - payload: { foo: 'bar' }, - attempts: 0, - maxAttempts: 3, - lastError: null, - nextRetryAt: new Date(Date.now() - 1000), - status: GradingJobStatus.PENDING, - createdAt: new Date(), - updatedAt: new Date(), - } as any; - - mockRepo.find.mockResolvedValue([job]); - - await service.processOnce(); - - // After first pass the job should have been saved with attempts=1 and status PENDING - expect(mockRepo.save).toHaveBeenCalled(); - const savedAfterFirst = mockRepo.save.mock.calls[0][0]; - expect(savedAfterFirst.attempts).toBe(1); - expect(savedAfterFirst.status).toBe(GradingJobStatus.PENDING); - - // simulate that find returns the job again for second pass - // ensure nextRetryAt is in the past so the retry will execute immediately - savedAfterFirst.nextRetryAt = new Date(Date.now() - 1000); - mockRepo.find.mockResolvedValue([savedAfterFirst]); - await service.processOnce(); - - // final save should mark COMPLETED - const finalSaved = mockRepo.save.mock.calls[mockRepo.save.mock.calls.length - 1][0]; - expect(finalSaved.status).toBe(GradingJobStatus.COMPLETED); - }); -}); diff --git a/BackendAcademy/src/jobs/grading-job.service.ts b/BackendAcademy/src/jobs/grading-job.service.ts deleted file mode 100644 index c56bdc9df1..0000000000 --- a/BackendAcademy/src/jobs/grading-job.service.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { Injectable, OnModuleInit, Logger, Inject, forwardRef } from '@nestjs/common'; -import { Repository } from 'typeorm'; -import { InjectRepository } from '@nestjs/typeorm'; -import { GradingJobEntity, GradingJobStatus } from './entities/grading-job.entity'; -import { GradingResultService } from '../submissions/grading-result.service'; - -@Injectable() -export class GradingJobService implements OnModuleInit { - private readonly logger = new Logger(GradingJobService.name); - private intervalHandle?: NodeJS.Timeout; - - constructor( - @InjectRepository(GradingJobEntity) - private readonly repo: Repository, - @Inject(forwardRef(() => GradingResultService)) - private readonly gradingResultService: GradingResultService, - ) {} - - async onModuleInit() { - // Start polling loop for retries every 10 seconds - this.intervalHandle = setInterval(() => this.processPendingJobs().catch(err => this.logger.error(err)), 10_000); - } - - async enqueueFailedJob(submissionId: string, payload: any, maxAttempts = 5) { - const job = this.repo.create({ - submissionId, - payload, - attempts: 0, - maxAttempts, - status: GradingJobStatus.PENDING, - nextRetryAt: new Date(), - }); - return this.repo.save(job); - } - - async processOnce() { - return this.processPendingJobs(); - } - - private async processPendingJobs() { - const now = new Date(); - const jobs = await this.repo.find({ - where: { - status: GradingJobStatus.PENDING, - // TypeORM can't express nextRetryAt <= now in object form reliably across DBs, - }, - order: { createdAt: 'ASC' }, - take: 10, - }); - - for (const job of jobs) { - if (job.nextRetryAt && job.nextRetryAt > now) continue; - - // mark in progress to avoid duplicate processing - job.status = GradingJobStatus.IN_PROGRESS; - await this.repo.save(job); - - try { - // Attempt to replay the grading result using saved payload - await this.gradingResultService.saveResult(job.submissionId, job.payload); - - job.status = GradingJobStatus.COMPLETED; - job.lastError = null; - await this.repo.save(job); - this.logger.debug(`Grading job ${job.id} completed`); - } catch (err: any) { - job.attempts = (job.attempts || 0) + 1; - job.lastError = err?.message ?? String(err); - - if (job.attempts >= (job.maxAttempts ?? 5)) { - job.status = GradingJobStatus.FAILED; - job.nextRetryAt = null; - this.logger.warn(`Grading job ${job.id} failed permanently: ${job.lastError}`); - } else { - job.status = GradingJobStatus.PENDING; - // exponential backoff (seconds) with jitter - const base = 2; - const delaySeconds = base * Math.pow(2, job.attempts - 1); - const jitterMs = Math.floor(Math.random() * 1000); - job.nextRetryAt = new Date(Date.now() + delaySeconds * 1000 + jitterMs); - this.logger.debug(`Grading job ${job.id} will retry in ${delaySeconds}s (attempt ${job.attempts})`); - } - - await this.repo.save(job); - } - } - } - - async shutdown() { - if (this.intervalHandle) { - clearInterval(this.intervalHandle); - this.intervalHandle = undefined; - } - } -} diff --git a/BackendAcademy/src/jobs/jobs.controller.ts b/BackendAcademy/src/jobs/jobs.controller.ts deleted file mode 100644 index ca52f91ef7..0000000000 --- a/BackendAcademy/src/jobs/jobs.controller.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Controller, Get, Param, ValidationPipe, UsePipes } from '@nestjs/common'; -import { JobsService } from './jobs.service'; - -@Controller('api/jobs') -export class JobsController { - constructor(private readonly jobsService: JobsService) {} - - /** - * Returns all registered cron schedules with their validation status - * and approximated next-run times. - */ - @Get('schedules') - getAllSchedules() { - return { - schedules: this.jobsService.getAllSchedules(), - total: this.jobsService.getAllSchedules().length, - }; - } - - /** - * Returns a single schedule by name. - */ - @Get('schedules/:name') - @UsePipes(new ValidationPipe({ transform: true })) - getSchedule(@Param('name') name: string) { - return this.jobsService.getSchedule(name) ?? { - error: `No schedule found for "${name}"`, - }; - } - - /** - * Validates all registered schedules and returns the results. - */ - @Get('validate') - validateAll() { - return { - results: this.jobsService.validateAll(), - }; - } -} diff --git a/BackendAcademy/src/jobs/jobs.module.ts b/BackendAcademy/src/jobs/jobs.module.ts deleted file mode 100644 index fd326b260c..0000000000 --- a/BackendAcademy/src/jobs/jobs.module.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Injectable, Module, OnModuleInit } from '@nestjs/common'; -import { JobsService } from './jobs.service'; -import { JobsController } from './jobs.controller'; - -@Injectable() -class EnvValidator implements OnModuleInit { - onModuleInit() { - const errors: string[] = []; - const nums: [string, number, number][] = [ - ['PORT', 1, 65535], - ['SESSION_TTL', 1, 86400], - ['AI_TIMEOUT', 1000, 30000], - ['ASS_MAX_SIZE', 1, 104857600], - ]; - for (const [name, min, max] of nums) { - const value = process.env[name]; - if (value && (Number.isNaN(+value) || +value < min || +value > max)) { - errors.push(`${name} must be between ${min} and ${max}`); - } - } - const bools = ['CRON_ENABLED', 'AI_ENABLED']; - for (const name of bools) { - const value = process.env[name]; - if (value && !['true', 'false', '1', '0'].includes(value.toLowerCase())) { - errors.push(`${name} must be a boolean`); - } - } - if (errors.length) throw new Error(`Environment validation failed: ${errors.join(', ')}`); - } -} - -@Module({ - controllers: [JobsController], - providers: [JobsService, EnvValidator], - exports: [JobsService], -}) -export class JobsModule {} diff --git a/BackendAcademy/src/jobs/jobs.service.ts b/BackendAcademy/src/jobs/jobs.service.ts deleted file mode 100644 index 7a3a7864ee..0000000000 --- a/BackendAcademy/src/jobs/jobs.service.ts +++ /dev/null @@ -1,544 +0,0 @@ -import { Injectable, Logger, OnModuleInit, OnApplicationShutdown } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { isFeatureEnabled } from '../config/env.schema'; - -/** - * Represents a parsed cron expression. - */ -export interface CronSchedule { - expression: string; - description: string; - isValid: boolean; - error?: string; - nextRuns: string[]; -} - -interface CronFields { - minute: string; - hour: string; - dayOfMonth: string; - month: string; - dayOfWeek: string; -} - -/** - * Result of a contract event replay job execution. - */ -export interface ReplayJobResult { - jobId: string; - contractId: string; - eventsProcessed: number; - status: 'completed' | 'failed'; - executedAt: Date; - durationMs: number; - error?: string; -} - -/** - * A competition tracked for automatic lifecycle transitions (#competition mode). - */ -interface TrackedCompetition { - competitionId: string; - endsAt: Date; - resetScheduledAt?: Date; -} - -/** - * Record of an automatic (or manually-logged) competition reset. - */ -export interface CompetitionResetLogEntry { - competitionId: string; - resetAt: Date; - reason: 'time-boxed-end' | 'manual'; -} - -@Injectable() -export class JobsService implements OnModuleInit, OnApplicationShutdown { - private readonly logger = new Logger(JobsService.name); - private readonly schedules = new Map(); - private heartbeatInterval: NodeJS.Timeout | null = null; - - /** #394: History of replay job executions */ - private readonly replayJobHistory: ReplayJobResult[] = []; - - constructor(private readonly configService: ConfigService) {} - - onModuleInit(): void { - this.loadSchedules(); - this.validateAll(); - - // #376: Start periodic heartbeat for readiness probes - this.lastHeartbeat = new Date(); - this.heartbeatInterval = setInterval(() => { - this.heartbeat(); - }, 30_000); // heartbeat every 30 seconds - - // #394: Log replay availability - const replayEnabled = isFeatureEnabled( - this.configService.get('CONTRACT_EVENT_REPLAY_ENABLED'), - ); - if (replayEnabled) { - this.logger.log('Contract event replay jobs are ENABLED'); - } else { - this.logger.log( - 'Contract event replay jobs are DISABLED. ' + - 'Set CONTRACT_EVENT_REPLAY_ENABLED=true to enable.', - ); - } - - // #competition: Log competition mode availability - if (this.isCompetitionModeEnabled()) { - this.logger.log('Competition mode jobs (time-boxed events/resets) are ENABLED'); - } else { - this.logger.log( - 'Competition mode jobs are DISABLED. ' + 'Set COMPETITION_MODE_ENABLED=true to enable.', - ); - } - } - - // ────────────────────────────────────────────────────────────────── - // Existing schedule management - // ────────────────────────────────────────────────────────────────── - - /** - * Preference cache for notification jobs (#385). - * Avoids repeated lookups during batch processing. - */ - private readonly notificationPrefsCache = new Map(); - - private loadSchedules(): void { - const entries: Array<{ name: string; key: string }> = [ - { name: 'cleanup', key: 'CRON_CLEANUP_SCHEDULE' }, - { name: 'analytics', key: 'CRON_ANALYTICS_SCHEDULE' }, - { name: 'notifications', key: 'CRON_NOTIFICATIONS_SCHEDULE' }, - // #394: Replay schedule for periodic event replay - { name: 'contract_replay', key: 'CRON_CONTRACT_REPLAY_SCHEDULE' }, - // #competition: Schedule for polling time-boxed competitions that need to end/reset - { name: 'competition_reset', key: 'CRON_COMPETITION_RESET_SCHEDULE' }, - ]; - - for (const entry of entries) { - const raw = this.configService.get(entry.key); - if (!raw) { - this.logger.warn(`No cron expression configured for ${entry.name}, using default`); - continue; - } - const schedule = this.parseCron(raw, entry.name); - if (!schedule.isValid) { - this.logger.error( - `Invalid cron expression for ${entry.name}: "${raw}" — ${schedule.error}`, - ); - } - this.schedules.set(entry.name, schedule); - } - } - - parseCron(expression: string, name: string): CronSchedule { - const trimmed = expression.trim(); - const fields = trimmed.split(/\s+/); - - if (fields.length !== 5) { - return { - expression: trimmed, - description: `Unknown schedule for ${name}`, - isValid: false, - error: `Cron expression must have exactly 5 fields (got ${fields.length}): "${trimmed}"`, - nextRuns: [], - }; - } - - const cronFields: CronFields = { - minute: fields[0], - hour: fields[1], - dayOfMonth: fields[2], - month: fields[3], - dayOfWeek: fields[4], - }; - - const validations: Array<{ field: string; value: string; min: number; max: number }> = [ - { field: 'minute', value: cronFields.minute, min: 0, max: 59 }, - { field: 'hour', value: cronFields.hour, min: 0, max: 23 }, - { field: 'day-of-month', value: cronFields.dayOfMonth, min: 1, max: 31 }, - { field: 'month', value: cronFields.month, min: 1, max: 12 }, - { field: 'day-of-week', value: cronFields.dayOfWeek, min: 0, max: 7 }, - ]; - - const cronFieldValidator = /^(\*|(\*\/)?\d+|\d+(-\d+)?)(,\d+(-\d+)?)*$/; - - for (const v of validations) { - if (v.value === '*' || v.value.startsWith('*/')) { - const numPart = v.value.startsWith('*/') ? v.value.slice(2) : '0'; - if (!/^\d+$/.test(numPart)) { - return this.invalidResult(trimmed, name, `${v.field}: invalid step value "${v.value}"`); - } - continue; - } - - const parts = v.value.split(','); - for (const part of parts) { - if (!cronFieldValidator.test(part)) { - return this.invalidResult(trimmed, name, `${v.field}: invalid field "${v.value}"`); - } - if (part.includes('-')) { - const [start, end] = part.split('-').map(Number); - if (start < v.min || end > v.max || start > end) { - return this.invalidResult( - trimmed, - name, - `${v.field}: range ${start}-${end} is out of bounds (${v.min}-${v.max})`, - ); - } - } else { - const num = Number(part); - if (num < v.min || num > v.max) { - return this.invalidResult( - trimmed, - name, - `${v.field}: value ${num} is out of bounds (${v.min}-${v.max})`, - ); - } - } - } - } - - const description = this.describeCron(cronFields, name); - const nextRuns = this.computeNextRuns(expression, 5); - - return { - expression: trimmed, - description, - isValid: true, - nextRuns, - }; - } - - validateAll(): Array<{ name: string; valid: boolean; error?: string }> { - const results: Array<{ name: string; valid: boolean; error?: string }> = []; - for (const [name, schedule] of this.schedules) { - results.push({ - name, - valid: schedule.isValid, - error: schedule.error, - }); - } - return results; - } - - getAllSchedules(): CronSchedule[] { - return Array.from(this.schedules.values()); - } - - getSchedule(name: string): CronSchedule | undefined { - return this.schedules.get(name); - } - - // ────────────────────────────────────────────────────────────────── - // #385: Notification preference verification for job processing - // ────────────────────────────────────────────────────────────────── - - /** - * Checks whether a user has enabled notification delivery. - * Uses a simple cache to avoid repeated lookups during batch processing. - * - * Returns true when the user has not explicitly disabled notifications, - * ensuring silent notification loss is prevented. - */ - shouldSendNotification(userId: string, channel: string): boolean { - const cacheKey = `${userId}:${channel}`; - if (this.notificationPrefsCache.has(cacheKey)) { - return this.notificationPrefsCache.get(cacheKey)!; - } - - // Default to enabled when no explicit preference is stored. - // In production this would query a UserPreferences table. - const enabled = true; - this.notificationPrefsCache.set(cacheKey, enabled); - return enabled; - } - - /** - * Invalidates the notification preference cache for a user. - * Called when user preferences are updated. - */ - clearNotificationPrefsCache(userId?: string): void { - if (userId) { - for (const key of this.notificationPrefsCache.keys()) { - if (key.startsWith(`${userId}:`)) { - this.notificationPrefsCache.delete(key); - } - } - } else { - this.notificationPrefsCache.clear(); - } - } - - // ────────────────────────────────────────────────────────────────── - // #394: Event replay job support - // ────────────────────────────────────────────────────────────────── - - /** - * Records a replay job execution result for audit trail. - */ - recordReplayJob(result: ReplayJobResult): void { - this.replayJobHistory.push(result); - this.logger.log( - `Replay job recorded: ${result.jobId} (${result.status}) — ${result.eventsProcessed} events in ${result.durationMs}ms`, - ); - - // Limit history size - if (this.replayJobHistory.length > 1000) { - this.replayJobHistory.splice(0, this.replayJobHistory.length - 1000); - } - } - - /** - * Returns replay job execution history. - */ - getReplayJobHistory(limit?: number): ReplayJobResult[] { - const history = [...this.replayJobHistory]; - history.sort((a, b) => b.executedAt.getTime() - a.executedAt.getTime()); - return limit ? history.slice(0, limit) : history; - } - - /** - * Checks whether the contract replay feature is enabled. - */ - isReplayEnabled(): boolean { - return isFeatureEnabled(this.configService.get('CONTRACT_EVENT_REPLAY_ENABLED')); - } - - /** - * Checks whether contract ingestion is enabled. - */ - isIngestionEnabled(): boolean { - return isFeatureEnabled(this.configService.get('CONTRACT_INGESTION_ENABLED')); - } - - // ────────────────────────────────────────────────────────────────── - // Competition lifecycle scheduling — time-boxed events & resets - // ────────────────────────────────────────────────────────────────── - - /** Competitions currently tracked for automatic lifecycle transitions. */ - private readonly trackedCompetitions = new Map(); - - /** History of competition resets performed by this job runner. */ - private readonly competitionResetLog: CompetitionResetLogEntry[] = []; - - isCompetitionModeEnabled(): boolean { - return isFeatureEnabled(this.configService.get('COMPETITION_MODE_ENABLED')); - } - - /** - * Registers a time-boxed competition so its end can be detected by the - * `competition_reset` cron tick. Call this right after creating a - * competition (e.g. from LeaderboardService.createCompetition). - */ - registerCompetitionLifecycle(competitionId: string, endsAt: Date): void { - this.trackedCompetitions.set(competitionId, { competitionId, endsAt }); - this.logger.log( - `Tracking competition ${competitionId} for lifecycle transition at ${endsAt.toISOString()}`, - ); - } - - /** Stops tracking a competition (e.g. it was cancelled before ending). */ - unregisterCompetitionLifecycle(competitionId: string): boolean { - return this.trackedCompetitions.delete(competitionId); - } - - /** - * Returns competitions whose time-box has elapsed and that haven't yet - * been flagged for reset. Intended to be polled on the - * CRON_COMPETITION_RESET_SCHEDULE cadence; the caller is responsible for - * actually resetting the competition's leaderboard (e.g. via - * LeaderboardService.resetCompetition) and then calling - * recordCompetitionReset() to close the loop. - */ - getCompetitionsDueForReset(): string[] { - const now = new Date(); - const due: string[] = []; - for (const tracked of this.trackedCompetitions.values()) { - if (tracked.endsAt <= now && !tracked.resetScheduledAt) { - tracked.resetScheduledAt = now; - due.push(tracked.competitionId); - } - } - return due; - } - - /** - * Records that a tracked competition was reset, then stops tracking it. - */ - recordCompetitionReset( - competitionId: string, - reason: 'time-boxed-end' | 'manual' = 'time-boxed-end', - ): void { - this.competitionResetLog.push({ competitionId, resetAt: new Date(), reason }); - this.trackedCompetitions.delete(competitionId); - this.logger.log(`Competition ${competitionId} reset recorded (${reason})`); - - if (this.competitionResetLog.length > 1000) { - this.competitionResetLog.splice(0, this.competitionResetLog.length - 1000); - } - } - - getCompetitionResetLog(limit?: number): CompetitionResetLogEntry[] { - const log = [...this.competitionResetLog].sort( - (a, b) => b.resetAt.getTime() - a.resetAt.getTime(), - ); - return limit ? log.slice(0, limit) : log; - } - - // ── Private helpers ────────────────────────────────────────────── - - private invalidResult(expression: string, name: string, error: string): CronSchedule { - return { - expression, - description: `Invalid schedule for ${name}`, - isValid: false, - error, - nextRuns: [], - }; - } - - private describeCron(fields: CronFields, name: string): string { - const desc = []; - const minute = fields.minute; - const hour = fields.hour; - - if (minute === '*' && hour === '*') { - desc.push('Runs every minute'); - } else if (minute.startsWith('*/') && hour === '*') { - desc.push(`Runs every ${minute.slice(2)} minutes`); - } else if (hour.startsWith('*/') && minute === '0') { - desc.push(`Runs every ${hour.slice(2)} hours`); - } else if (minute === '0' && hour === '0') { - desc.push('Runs at midnight'); - } else { - desc.push(`Runs at ${hour}:${minute.padStart(2, '0')}`); - } - - desc.push(`(${name})`); - return desc.join(' '); - } - - private computeNextRuns(_expression: string, count: number): string[] { - const runs: string[] = []; - const now = new Date(); - for (let i = 1; i <= count; i++) { - const next = new Date(now.getTime() + i * 60 * 60 * 1000); - runs.push(next.toISOString()); - } - return runs; - } - - // --------------------------------------------------------------------------- - // Webhook Retry Scheduling — Issue #412 - // --------------------------------------------------------------------------- - - /** Queue of pending webhook retries, keyed by webhookId. */ - private readonly pendingWebhookRetries = new Map< - string, - { - webhookId: string; - attempt: number; - nextRetryAt: Date; - lastError?: string; - } - >(); - - /** - * Schedules a webhook retry with exponential backoff and jitter. - */ - scheduleWebhookRetry( - webhookId: string, - attempt: number, - baseBackoffMs: number, - maxBackoffMs: number, - ): Date { - const exponential = Math.min(baseBackoffMs * Math.pow(2, attempt - 1), maxBackoffMs); - const jitter = exponential * (0.5 + Math.random() * 0.5); - const nextRetryAt = new Date(Date.now() + Math.floor(jitter)); - - this.pendingWebhookRetries.set(webhookId, { - webhookId, - attempt, - nextRetryAt, - }); - - this.logger.log( - `Scheduled webhook retry for ${webhookId} attempt ${attempt} at ${nextRetryAt.toISOString()}`, - ); - return nextRetryAt; - } - - /** - * Returns all webhook retries that are due for execution. - */ - getDueWebhookRetries(): Array<{ webhookId: string; attempt: number }> { - const now = new Date(); - const due: Array<{ webhookId: string; attempt: number }> = []; - for (const [id, entry] of this.pendingWebhookRetries) { - if (entry.nextRetryAt <= now) { - due.push({ webhookId: id, attempt: entry.attempt }); - this.pendingWebhookRetries.delete(id); - } - } - return due; - } - - /** - * Removes a scheduled retry (e.g. on successful delivery). - */ - cancelWebhookRetry(webhookId: string): boolean { - return this.pendingWebhookRetries.delete(webhookId); - } - - onApplicationShutdown(signal?: string) { - if (this.heartbeatInterval) { - clearInterval(this.heartbeatInterval); - this.heartbeatInterval = null; - } - this.workerReady = false; - this.logger.log(`JobsService shut down gracefully (signal: ${signal}).`); - } - - // --------------------------------------------------------------------------- - // Readiness probes — Issue #376 - // --------------------------------------------------------------------------- - - private workerReady = true; - private lastHeartbeat: Date = new Date(); - - /** - * Returns true when background workers are initialized and accepting jobs. - * Used by the readiness probe to determine if this instance is traffic-ready. - */ - isReady(): boolean { - // Workers are considered ready if initialized and heartbeat is fresh - // (within the last 60 seconds). - const heartbeatFresh = - Date.now() - this.lastHeartbeat.getTime() < 60_000; - return this.workerReady && heartbeatFresh; - } - - /** - * Returns the current depth of pending/scheduled job queues. - */ - getQueueDepth(): number { - return this.pendingWebhookRetries.size; - } - - /** - * Returns the timestamp of the last worker heartbeat. - */ - getLastHeartbeat(): Date { - return this.lastHeartbeat; - } - - /** - * Call this periodically from the worker loop to signal aliveness. - */ - heartbeat(): void { - this.lastHeartbeat = new Date(); - } -} diff --git a/BackendAcademy/src/leaderboard/dto/create-competition.dto.ts b/BackendAcademy/src/leaderboard/dto/create-competition.dto.ts deleted file mode 100644 index d0f4158942..0000000000 --- a/BackendAcademy/src/leaderboard/dto/create-competition.dto.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { LeaderboardEntry } from '../interfaces/leaderboard.interface'; - -export type CompetitionStatus = 'scheduled' | 'active' | 'ended' | 'reset'; -export type CompetitionScope = 'global' | 'weekly' | 'course'; - -/** - * A time-boxed competition event. `startsAt`/`endsAt` define the window in - * which activity counts toward the competition's live leaderboard. - */ -export interface CompetitionEvent { - id: string; - name: string; - scope: CompetitionScope; - courseId?: string; - startsAt: Date; - endsAt: Date; - status: CompetitionStatus; - createdAt: Date; - resetAt?: Date; - resetCount: number; -} - -export type CompetitionSnapshotEntry = LeaderboardEntry; - -/** - * Archived standings captured at the moment a competition is reset. - * Used to power summary exports even after live entries are cleared. - */ -export interface CompetitionResetRecord { - competitionId: string; - resetAt: Date; - archivedEntries: CompetitionSnapshotEntry[]; - participantCount: number; -} - -export interface CompetitionLeaderboardResponse { - competition: CompetitionEvent; - entries: CompetitionSnapshotEntry[]; - total: number; - hasMore: boolean; -} - -/** - * Point-in-time exportable summary of a competition's standings and - * lifecycle, suitable for download or inclusion in a reports payload. - */ -export interface CompetitionSummaryExport { - competitionId: string; - name: string; - scope: CompetitionScope; - courseId?: string; - window: { startsAt: string; endsAt: string }; - status: CompetitionStatus; - generatedAt: string; - totalParticipants: number; - totalResets: number; - topEntries: CompetitionSnapshotEntry[]; -} diff --git a/BackendAcademy/src/leaderboard/dto/get-leaderboard.dto.ts b/BackendAcademy/src/leaderboard/dto/get-leaderboard.dto.ts deleted file mode 100644 index 657601d173..0000000000 --- a/BackendAcademy/src/leaderboard/dto/get-leaderboard.dto.ts +++ /dev/null @@ -1,32 +0,0 @@ -export type LeaderboardScope = 'global' | 'weekly' | 'course'; - -export class GetLeaderboardDto { - timeRange?: 'daily' | 'weekly' | 'monthly' | 'allTime'; - category?: string; - difficulty?: string; - /** - * High-level leaderboard grouping. - * - `global` (default): cross-course, ordered by overall score. - * - `weekly`: pins `effectiveTimeRange=weekly` when no timeRange is set. - * (Currently a no-op against the sample data - see service TODO.) - * - `course`: requires `courseId`; returns only entries in that course. - */ - scope?: LeaderboardScope; - /** - * Required when scope=`course`. Filters leaderboard to a single course. - * NOTE: this field is currently a stub identifier; the real - * implementation should join a course-membership repository. - * - * Precedence with `timeRange`: if both `scope='weekly'` and an explicit - * `timeRange` are supplied, `timeRange` wins for the resolved time bucket - * reported back to the client. - * - * NOTE: this DTO does not declare `class-validator` decorators - consistent - * with other DTOs in the project. Out-of-range `scope` values will therefore - * pass through to the service as strings. - */ - courseId?: string; - limit?: number; - offset?: number; - userId?: string; -} \ No newline at end of file diff --git a/BackendAcademy/src/leaderboard/dto/placeholder.txt b/BackendAcademy/src/leaderboard/dto/placeholder.txt deleted file mode 100644 index dec3c8e7db..0000000000 --- a/BackendAcademy/src/leaderboard/dto/placeholder.txt +++ /dev/null @@ -1 +0,0 @@ -Placeholder for dto directory \ No newline at end of file diff --git a/BackendAcademy/src/leaderboard/interfaces/competition.interface.ts b/BackendAcademy/src/leaderboard/interfaces/competition.interface.ts deleted file mode 100644 index 2f2e2516c5..0000000000 --- a/BackendAcademy/src/leaderboard/interfaces/competition.interface.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { LeaderboardEntry } from './leaderboard.interface'; - -export type CompetitionStatus = 'scheduled' | 'active' | 'ended' | 'reset'; -export type CompetitionScope = 'global' | 'weekly' | 'course'; - -/** - * A time-boxed competition event. `startsAt`/`endsAt` define the window in - * which activity counts toward the competition's live leaderboard. - */ -export interface CompetitionEvent { - id: string; - name: string; - scope: CompetitionScope; - courseId?: string; - startsAt: Date; - endsAt: Date; - status: CompetitionStatus; - createdAt: Date; - resetAt?: Date; - resetCount: number; -} - -export type CompetitionSnapshotEntry = LeaderboardEntry; - -/** - * Archived standings captured at the moment a competition is reset. - * Used to power summary exports even after live entries are cleared. - */ -export interface CompetitionResetRecord { - competitionId: string; - resetAt: Date; - archivedEntries: CompetitionSnapshotEntry[]; - participantCount: number; -} - -export interface CompetitionLeaderboardResponse { - competition: CompetitionEvent; - entries: CompetitionSnapshotEntry[]; - total: number; - hasMore: boolean; -} - -/** - * Point-in-time exportable summary of a competition's standings and - * lifecycle, suitable for download or inclusion in a reports payload. - */ -export interface CompetitionSummaryExport { - competitionId: string; - name: string; - scope: CompetitionScope; - courseId?: string; - window: { startsAt: string; endsAt: string }; - status: CompetitionStatus; - generatedAt: string; - totalParticipants: number; - totalResets: number; - topEntries: CompetitionSnapshotEntry[]; -} diff --git a/BackendAcademy/src/leaderboard/interfaces/leaderboard.interface.ts b/BackendAcademy/src/leaderboard/interfaces/leaderboard.interface.ts deleted file mode 100644 index c6ffc457e3..0000000000 --- a/BackendAcademy/src/leaderboard/interfaces/leaderboard.interface.ts +++ /dev/null @@ -1,30 +0,0 @@ -export interface LeaderboardEntry { - rank: number; - userId: string; - username: string; - avatarUrl?: string; - score: number; - challengesCompleted: number; - accuracy: number; - streak: number; -} - -import type { LeaderboardScope } from '../dto/get-leaderboard.dto'; - -export interface LeaderboardFilters { - timeRange?: 'daily' | 'weekly' | 'monthly' | 'allTime'; - category?: string; - difficulty?: string; - scope?: LeaderboardScope; - courseId?: string; - limit?: number; - offset?: number; -} - -export interface LeaderboardResponse { - entries: LeaderboardEntry[]; - total: number; - hasMore: boolean; - filters: LeaderboardFilters; - userRank?: LeaderboardEntry; -} diff --git a/BackendAcademy/src/leaderboard/interfaces/placeholder.txt b/BackendAcademy/src/leaderboard/interfaces/placeholder.txt deleted file mode 100644 index b4201277f0..0000000000 --- a/BackendAcademy/src/leaderboard/interfaces/placeholder.txt +++ /dev/null @@ -1 +0,0 @@ -Placeholder for interfaces directory \ No newline at end of file diff --git a/BackendAcademy/src/leaderboard/leaderboard.controller.ts b/BackendAcademy/src/leaderboard/leaderboard.controller.ts deleted file mode 100644 index 70cea203ad..0000000000 --- a/BackendAcademy/src/leaderboard/leaderboard.controller.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Controller, Get, Query } from '@nestjs/common'; -import { LeaderboardService } from './leaderboard.service'; -import { GetLeaderboardDto } from './dto/get-leaderboard.dto'; -import { LeaderboardResponse } from './interfaces/leaderboard.interface'; - -@Controller('leaderboard') -export class LeaderboardController { - constructor(private readonly leaderboardService: LeaderboardService) {} - - @Get() - async getLeaderboard(@Query() getLeaderboardDto: GetLeaderboardDto): Promise { - return this.leaderboardService.getLeaderboard(getLeaderboardDto); - } -} \ No newline at end of file diff --git a/BackendAcademy/src/leaderboard/leaderboard.module.ts b/BackendAcademy/src/leaderboard/leaderboard.module.ts deleted file mode 100644 index 81281adac4..0000000000 --- a/BackendAcademy/src/leaderboard/leaderboard.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from '@nestjs/common'; -import { LeaderboardController } from './leaderboard.controller'; -import { LeaderboardService } from './leaderboard.service'; - -@Module({ - controllers: [LeaderboardController], - providers: [LeaderboardService], - exports: [LeaderboardService], -}) -export class LeaderboardModule {} \ No newline at end of file diff --git a/BackendAcademy/src/leaderboard/leaderboard.service.ts b/BackendAcademy/src/leaderboard/leaderboard.service.ts deleted file mode 100644 index ad0394a1eb..0000000000 --- a/BackendAcademy/src/leaderboard/leaderboard.service.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { createHash } from 'crypto'; -import { GetLeaderboardDto } from './dto/get-leaderboard.dto'; -import { LeaderboardEntry, LeaderboardResponse } from './interfaces/leaderboard.interface'; - -/** BA-121: stable deterministic ID derived from username — survives restarts */ -function stableId(username: string): string { - return createHash('sha256').update(`leaderboard:${username}`).digest('hex').slice(0, 36); -} - -@Injectable() -export class LeaderboardService { - // BA-121: IDs are now deterministic (SHA-256 of username) so snapshots are - // cacheable and userId-based rank queries work across process restarts. - private sampleUsers: Omit[] = [ - { - userId: stableId('rustmaster'), - username: 'rustmaster', - avatarUrl: 'https://example.com/avatars/rustmaster.png', - score: 15420, - challengesCompleted: 127, - accuracy: 94.5, - streak: 45, - }, - { - userId: stableId('codewarrior'), - username: 'codewarrior', - avatarUrl: 'https://example.com/avatars/codewarrior.png', - score: 14890, - challengesCompleted: 118, - accuracy: 92.3, - streak: 32, - }, - { - userId: stableId('memorieslock'), - username: 'memorieslock', - avatarUrl: 'https://example.com/avatars/memorieslock.png', - score: 14250, - challengesCompleted: 112, - accuracy: 91.8, - streak: 28, - }, - { - userId: stableId('rustacean'), - username: 'rustacean', - avatarUrl: 'https://example.com/avatars/rustacean.png', - score: 13780, - challengesCompleted: 105, - accuracy: 89.7, - streak: 21, - }, - { - userId: stableId('systemshade'), - username: 'systemshade', - avatarUrl: 'https://example.com/avatars/systemshade.png', - score: 13150, - challengesCompleted: 98, - accuracy: 88.2, - streak: 18, - }, - { - userId: stableId('codelover'), - username: 'codelover', - avatarUrl: 'https://example.com/avatars/codelover.png', - score: 12890, - challengesCompleted: 92, - accuracy: 87.5, - streak: 15, - }, - { - userId: stableId('learningdev'), - username: 'learningdev', - avatarUrl: 'https://example.com/avatars/learningdev.png', - score: 11560, - challengesCompleted: 85, - accuracy: 85.3, - streak: 12, - }, - { - userId: stableId('newbiecoder'), - username: 'newbiecoder', - avatarUrl: 'https://example.com/avatars/newbiecoder.png', - score: 9870, - challengesCompleted: 67, - accuracy: 82.1, - streak: 8, - }, - ]; - - async getLeaderboard(getLeaderboardDto: GetLeaderboardDto): Promise { - const { - timeRange = 'allTime', - category, - difficulty, - scope, - courseId, - limit = 10, - offset = 0, - userId, - } = getLeaderboardDto; - - // scope=`weekly` is currently a NO-OP filter: it pins `effectiveTimeRange` - // to 'weekly' only when the caller didn't supply an explicit timeRange - // (so timeRange wins if both are passed). The underlying sample data is - // NOT bucketed by date - this is a stub marker only. - // TODO: when LeaderboardsRepository lands, scope='weekly' should filter to - // entries with activity inside the rolling 7-day window. - const effectiveTimeRange = scope === 'weekly' && !timeRange ? 'weekly' : timeRange; - - // scope=`course` returns only entries that belong to the requested course. - // NOTE: this is a stub backed by the in-memory sample set whose userIds are - // randomized `uuidv4()` values. We can't join a real CourseMembership table - // until IDs become stable, so we use a deterministic-but-arbitrary split: - // bucket = courseId.length % 2 -> every other entry by index. - // TODO: replace with a CourseRepository.findByUserId lookup. - // courseId sensitivity: the bucketing changes with courseId *length*, so - // `course-1` and `course-12` will return different subsets - intentional - // stub behavior, will be removed when the real lookup lands. - let candidates = [...this.sampleUsers]; - if (scope === 'course' && courseId) { - const bucket = courseId.length % 2; - candidates = candidates.filter((_, idx) => idx % 2 === bucket); - } - if (scope === 'course' && !courseId) { - // course scope requires a courseId - return nothing rather than guess. - candidates = []; - } - - // TODO: wire timeRange/category/difficulty against a real LeaderboardsRepository. - const sortedEntries = [...candidates] - .sort((a, b) => b.score - a.score) - .map((entry, index) => ({ - ...entry, - rank: index + 1, - })); - - const paginatedEntries = sortedEntries.slice(offset, offset + limit); - const total = sortedEntries.length; - const hasMore = offset + limit < total; - - let userRank: LeaderboardEntry | undefined; - if (userId) { - userRank = sortedEntries.find((entry) => entry.userId === userId); - } - - return { - entries: paginatedEntries, - total, - hasMore, - filters: { - timeRange: effectiveTimeRange, - category, - difficulty, - scope, - courseId, - limit, - offset, - }, - userRank, - }; - } -} \ No newline at end of file diff --git a/BackendAcademy/src/leaderboard/placeholder.txt b/BackendAcademy/src/leaderboard/placeholder.txt deleted file mode 100644 index a7bc05b15b..0000000000 --- a/BackendAcademy/src/leaderboard/placeholder.txt +++ /dev/null @@ -1 +0,0 @@ -Placeholder for leaderboard directory \ No newline at end of file diff --git a/BackendAcademy/src/leaderboard/repositories/index.ts b/BackendAcademy/src/leaderboard/repositories/index.ts deleted file mode 100644 index e9ea063377..0000000000 --- a/BackendAcademy/src/leaderboard/repositories/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { ILeaderboardRepository } from './leaderboard.repository.interface'; -export { InMemoryLeaderboardRepository } from './leaderboard.repository.in-memory'; diff --git a/BackendAcademy/src/leaderboard/repositories/leaderboard.repository.in-memory.ts b/BackendAcademy/src/leaderboard/repositories/leaderboard.repository.in-memory.ts deleted file mode 100644 index 85b715ed90..0000000000 --- a/BackendAcademy/src/leaderboard/repositories/leaderboard.repository.in-memory.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { createHash } from 'crypto'; -import { LeaderboardEntry } from '../interfaces/leaderboard.interface'; -import { ILeaderboardRepository } from './leaderboard.repository.interface'; - -/** - * BA-121: Derive stable, deterministic user IDs from usernames so leaderboard - * snapshots are cacheable and rank lookups by userId work across restarts. - * Previously each process start generated fresh uuidv4() values, making every - * snapshot non-deterministic and preventing userId-based rank queries. - */ -function stableId(username: string): string { - return createHash('sha256').update(`leaderboard:${username}`).digest('hex').slice(0, 36); -} - -/** - * In-memory implementation of the leaderboard repository. - * Stores leaderboard entries in process-local arrays. - * Scores are fixed snapshots derived from activity counters (challengesCompleted, - * accuracy, streak) — the formula mirrors what a real score-computation job - * would produce, so rankings are deterministic and tie-breaking is stable. - */ -export class InMemoryLeaderboardRepository implements ILeaderboardRepository { - private sampleUsers: Omit[] = [ - { - userId: stableId('rustmaster'), - username: 'rustmaster', - avatarUrl: 'https://example.com/avatars/rustmaster.png', - score: 15420, - challengesCompleted: 127, - accuracy: 94.5, - streak: 45, - }, - { - userId: stableId('codewarrior'), - username: 'codewarrior', - avatarUrl: 'https://example.com/avatars/codewarrior.png', - score: 14890, - challengesCompleted: 118, - accuracy: 92.3, - streak: 32, - }, - { - userId: stableId('memorieslock'), - username: 'memorieslock', - avatarUrl: 'https://example.com/avatars/memorieslock.png', - score: 14250, - challengesCompleted: 112, - accuracy: 91.8, - streak: 28, - }, - { - userId: stableId('rustacean'), - username: 'rustacean', - avatarUrl: 'https://example.com/avatars/rustacean.png', - score: 13780, - challengesCompleted: 105, - accuracy: 89.7, - streak: 21, - }, - { - userId: stableId('systemshade'), - username: 'systemshade', - avatarUrl: 'https://example.com/avatars/systemshade.png', - score: 13150, - challengesCompleted: 98, - accuracy: 88.2, - streak: 18, - }, - { - userId: stableId('codelover'), - username: 'codelover', - avatarUrl: 'https://example.com/avatars/codelover.png', - score: 12890, - challengesCompleted: 92, - accuracy: 87.5, - streak: 15, - }, - { - userId: stableId('learningdev'), - username: 'learningdev', - avatarUrl: 'https://example.com/avatars/learningdev.png', - score: 11560, - challengesCompleted: 85, - accuracy: 85.3, - streak: 12, - }, - { - userId: stableId('newbiecoder'), - username: 'newbiecoder', - avatarUrl: 'https://example.com/avatars/newbiecoder.png', - score: 9870, - challengesCompleted: 67, - accuracy: 82.1, - streak: 8, - }, - ]; - - getAllEntries(): Omit[] { - return [...this.sampleUsers]; - } - - getFilteredEntries(filters: { - courseId?: string; - timeRange?: string; - category?: string; - difficulty?: string; - }): Omit[] { - let candidates = [...this.sampleUsers]; - - if (filters.courseId) { - const bucket = filters.courseId.length % 2; - candidates = candidates.filter((_, idx) => idx % 2 === bucket); - } - - return candidates; - } - - getEntryByUserId(userId: string): Omit | undefined { - return this.sampleUsers.find((entry) => entry.userId === userId); - } - - upsertEntry(entry: Omit): void { - const existingIndex = this.sampleUsers.findIndex((e) => e.userId === entry.userId); - if (existingIndex >= 0) { - this.sampleUsers[existingIndex] = entry; - } else { - this.sampleUsers.push(entry); - } - } - - clearAll(): void { - this.sampleUsers = []; - } -} diff --git a/BackendAcademy/src/leaderboard/repositories/leaderboard.repository.interface.ts b/BackendAcademy/src/leaderboard/repositories/leaderboard.repository.interface.ts deleted file mode 100644 index 070ea6d064..0000000000 --- a/BackendAcademy/src/leaderboard/repositories/leaderboard.repository.interface.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { LeaderboardEntry } from '../interfaces/leaderboard.interface'; - -/** - * Repository interface for leaderboard storage. - * Isolates persistence concerns from business logic. - */ -export interface ILeaderboardRepository { - /** - * Get all leaderboard entries. - */ - getAllEntries(): Omit[]; - - /** - * Get entries filtered by various criteria. - */ - getFilteredEntries(filters: { - courseId?: string; - timeRange?: string; - category?: string; - difficulty?: string; - }): Omit[]; - - /** - * Get a specific entry by user ID. - */ - getEntryByUserId(userId: string): Omit | undefined; - - /** - * Add or update a leaderboard entry. - */ - upsertEntry(entry: Omit): void; - - /** - * Clear all leaderboard data (useful for testing). - */ - clearAll(): void; -} diff --git a/BackendAcademy/src/lessons/dto/create-lesson.dto.ts b/BackendAcademy/src/lessons/dto/create-lesson.dto.ts deleted file mode 100644 index cc8892a2bb..0000000000 --- a/BackendAcademy/src/lessons/dto/create-lesson.dto.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { IsString, IsNumber, IsOptional, IsArray, IsBoolean } from 'class-validator'; - -export class CreateLessonDto { - @IsString() - courseId: string; - - @IsString() - title: string; - - @IsString() - content: string; - - @IsNumber() - order: number; - - @IsOptional() - @IsNumber() - duration?: number; - - @IsOptional() - @IsNumber() - xpReward?: number; - - @IsOptional() - @IsArray() - @IsString({ each: true }) - prerequisites?: string[]; - - @IsOptional() - @IsBoolean() - isPublished?: boolean; -} \ No newline at end of file diff --git a/BackendAcademy/src/lessons/dto/update-lesson.dto.ts b/BackendAcademy/src/lessons/dto/update-lesson.dto.ts deleted file mode 100644 index 029ec8a52a..0000000000 --- a/BackendAcademy/src/lessons/dto/update-lesson.dto.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { IsString, IsNumber, IsOptional, IsArray, IsBoolean } from 'class-validator'; - -export class UpdateLessonDto { - @IsOptional() - @IsString() - title?: string; - - @IsOptional() - @IsString() - content?: string; - - @IsOptional() - @IsNumber() - order?: number; - - @IsOptional() - @IsNumber() - duration?: number; - - @IsOptional() - @IsNumber() - xpReward?: number; - - @IsOptional() - @IsArray() - @IsString({ each: true }) - prerequisites?: string[]; - - @IsOptional() - @IsBoolean() - isPublished?: boolean; -} diff --git a/BackendAcademy/src/lessons/index.ts b/BackendAcademy/src/lessons/index.ts deleted file mode 100644 index f1e17cdc01..0000000000 --- a/BackendAcademy/src/lessons/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { LessonModule } from './lesson.module'; -export { LessonService } from './lesson.service'; -export { LessonEntity } from './lesson.entity'; -export { CreateLessonDto } from './dto/create-lesson.dto'; -export { UpdateLessonDto } from './dto/update-lesson.dto'; diff --git a/BackendAcademy/src/lessons/interfaces/lesson.interface.ts b/BackendAcademy/src/lessons/interfaces/lesson.interface.ts deleted file mode 100644 index 6f34b9b688..0000000000 --- a/BackendAcademy/src/lessons/interfaces/lesson.interface.ts +++ /dev/null @@ -1,13 +0,0 @@ -export interface ILesson { - id: string; - courseId: string; - title: string; - content: string; - order: number; - duration: number; - xpReward: number; - prerequisites: string[]; - isPublished: boolean; - createdAt: Date; - updatedAt: Date; -} diff --git a/BackendAcademy/src/lessons/lesson.controller.ts b/BackendAcademy/src/lessons/lesson.controller.ts deleted file mode 100644 index 5e58e4c63a..0000000000 --- a/BackendAcademy/src/lessons/lesson.controller.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { - Controller, - Get, - Post, - Put, - Delete, - Body, - Param, - ParseUUIDPipe, - HttpCode, - HttpStatus, -} from '@nestjs/common'; -import { LessonService } from './lesson.service'; -import { CreateLessonDto } from './dto/create-lesson.dto'; -import { UpdateLessonDto } from './dto/update-lesson.dto'; - -@Controller('lessons') -export class LessonController { - constructor(private readonly lessonService: LessonService) {} - - @Post() - async create(@Body() dto: CreateLessonDto) { - return this.lessonService.create(dto); - } - - @Get() - async findAll() { - return this.lessonService.findAll(); - } - - @Get('course/:courseId') - async findByCourseId(@Param('courseId') courseId: string) { - return this.lessonService.findByCourseId(courseId); - } - - @Get(':id') - async findById(@Param('id', ParseUUIDPipe) id: string) { - return this.lessonService.findById(id); - } - - @Put(':id') - async update( - @Param('id', ParseUUIDPipe) id: string, - @Body() dto: UpdateLessonDto, - ) { - return this.lessonService.update(id, dto); - } - - @Delete(':id') - @HttpCode(HttpStatus.NO_CONTENT) - async remove(@Param('id', ParseUUIDPipe) id: string) { - await this.lessonService.remove(id); - } -} diff --git a/BackendAcademy/src/lessons/lesson.entity.ts b/BackendAcademy/src/lessons/lesson.entity.ts deleted file mode 100644 index 160e47bf14..0000000000 --- a/BackendAcademy/src/lessons/lesson.entity.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { v4 as uuidv4 } from 'uuid'; - -export class LessonEntity { - id: string; - courseId: string; - title: string; - content: string; - order: number; - duration: number; - xpReward: number; - prerequisites: string[]; - version: number; - isPublished: boolean; - createdAt: Date; - updatedAt: Date; - - constructor(partial: Partial) { - Object.assign(this, partial); - this.id = this.id || uuidv4(); - this.createdAt = this.createdAt || new Date(); - this.updatedAt = this.updatedAt || new Date(); - this.isPublished = this.isPublished ?? false; - this.prerequisites = this.prerequisites || []; - this.xpReward = this.xpReward || 0; - this.duration = this.duration || 0; - this.version = this.version && this.version >= 1 ? this.version : 1; - } -} diff --git a/BackendAcademy/src/lessons/lesson.module.ts b/BackendAcademy/src/lessons/lesson.module.ts deleted file mode 100644 index ae501130ee..0000000000 --- a/BackendAcademy/src/lessons/lesson.module.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Module } from '@nestjs/common'; -import { LessonController } from './lesson.controller'; -import { LessonService } from './lesson.service'; -import { SearchModule } from '../search/search.module'; -import { RedisModule } from '../redis/redis.module'; - -@Module({ - imports: [SearchModule, RedisModule], - controllers: [LessonController], - providers: [LessonService], - exports: [LessonService], -}) -export class LessonModule {} diff --git a/BackendAcademy/src/lessons/lesson.service.ts b/BackendAcademy/src/lessons/lesson.service.ts deleted file mode 100644 index cc88afa454..0000000000 --- a/BackendAcademy/src/lessons/lesson.service.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { Injectable, Logger, NotFoundException, Optional } from '@nestjs/common'; -import { LessonEntity } from './lesson.entity'; -import { CreateLessonDto } from './dto/create-lesson.dto'; -import { UpdateLessonDto } from './dto/update-lesson.dto'; -import { SearchIndexerService } from '../search/search-indexer.service'; -import { RedisService } from '../redis/redis.service'; - -@Injectable() -export class LessonService { - private readonly logger = new Logger(LessonService.name); - private readonly lessons: Map = new Map(); - - constructor( - @Optional() private readonly searchIndexer?: SearchIndexerService, - @Optional() private readonly redisService?: RedisService, - ) {} - - async create(dto: CreateLessonDto): Promise { - const lesson = new LessonEntity({ - id: crypto.randomUUID(), - ...dto, - }); - this.lessons.set(lesson.id, lesson); - // #379: a freshly created lesson invalidates any cached lookups for it - await this.redisService?.invalidateContentCache('lesson', lesson.id); - return lesson; - } - - async findAll(): Promise { - return Array.from(this.lessons.values()); - } - - async findByCourseId(courseId: string): Promise { - return Array.from(this.lessons.values()) - .filter((l) => l.courseId === courseId) - .sort((a, b) => a.order - b.order); - } - - /** - * Same as findByCourseId, but only returns lessons that are safe to show - * learners: published, and carrying a valid version number. This is what - * course-navigation endpoints should call instead of findByCourseId, so a - * broken/incomplete lesson never shows up in the nav list. - */ - async findPublishedByCourseId(courseId: string): Promise { - return (await this.findByCourseId(courseId)).filter((l) => - this.isServeable(l), - ); - } - - async findById(id: string): Promise { - return this.lessons.get(id) || null; - } - - /** - * Fetches a lesson for learner consumption. Throws a structured - * NotFoundException (rather than returning null/undefined or letting a - * downstream consumer dereference a missing field) whenever the lesson - * doesn't exist, has no version, or isn't published. This is the - * boundary that used to leak raw server errors to the frontend. - */ - async findServeableById(id: string): Promise { - const lesson = this.lessons.get(id); - - if (!lesson) { - throw new NotFoundException({ - error: 'LESSON_NOT_FOUND', - message: `Lesson with ID ${id} not found`, - }); - } - - if (!this.isServeable(lesson)) { - throw new NotFoundException({ - error: 'LESSON_UNAVAILABLE', - message: `Lesson ${id} is missing a version or is unpublished`, - }); - } - - return lesson; - } - - async update( - id: string, - dto: UpdateLessonDto, - ): Promise { - const lesson = this.lessons.get(id); - if (!lesson) return null; - Object.assign(lesson, dto, { updatedAt: new Date() }); - // #379: edits to lesson content must invalidate any cached lookups for it - // and for the parent course. Search index updates for lessons flow through - // their owning course (see #369 — SearchIndexerService indexes courses, - // not lessons directly). - await this.redisService?.invalidateContentCache('lesson', id); - if (lesson.courseId) { - await this.redisService?.invalidateContentCache('course', lesson.courseId); - } - return lesson; - } - - async remove(id: string): Promise { - const lesson = this.lessons.get(id); - const deleted = this.lessons.delete(id); - if (deleted && lesson) { - // #369: lesson removal also invalidates the parent course's cache - await this.redisService?.invalidateContentCache('lesson', id); - if (lesson.courseId) { - await this.redisService?.invalidateContentCache('course', lesson.courseId); - } - } - return deleted; - } - - /** - * A lesson is safe to serve to a learner only if it has a valid version - * number and is explicitly published. Missing/undefined version or - * isPublished previously fell through as "truthy enough" and surfaced as - * a downstream crash or blank navigation entry instead of a clean 404. - */ - private isServeable(lesson: LessonEntity): boolean { - return ( - Number.isFinite(lesson.version) && - lesson.version >= 1 && - lesson.isPublished === true - ); - } -} diff --git a/BackendAcademy/src/logging/error-tracking.service.spec.ts b/BackendAcademy/src/logging/error-tracking.service.spec.ts deleted file mode 100644 index fbe4e258e3..0000000000 --- a/BackendAcademy/src/logging/error-tracking.service.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { ErrorTrackingService, ErrorTrackingReport } from './error-tracking.service'; - -describe('ErrorTrackingService', () => { - let service: ErrorTrackingService; - - beforeEach(() => { - service = new ErrorTrackingService(); - }); - - it('captures an exception as a structured placeholder report', () => { - const report = service.captureException(new Error('boom'), 'users'); - - expect(report).toEqual( - expect.objectContaining({ - source: 'placeholder', - context: 'users', - message: 'boom', - }), - ); - expect(report.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/); - }); - - it('initializes process handlers without throwing', () => { - expect(() => service.onModuleInit()).not.toThrow(); - }); -}); diff --git a/BackendAcademy/src/logging/error-tracking.service.ts b/BackendAcademy/src/logging/error-tracking.service.ts deleted file mode 100644 index 5abb3e317d..0000000000 --- a/BackendAcademy/src/logging/error-tracking.service.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; - -export interface ErrorTrackingReport { - source: 'placeholder'; - context?: string; - message: string; - stack?: string; - timestamp: string; -} - -export interface AuditLogEntry { - action: string; - actor: string; - resourceType: string; - resourceId: string; - details: Record; - timestamp: string; -} - -@Injectable() -export class ErrorTrackingService implements OnModuleInit { - private readonly logger = new Logger(ErrorTrackingService.name); - - onModuleInit(): void { - process.on('uncaughtException', (error: Error) => { - this.captureException(error, 'process'); - }); - - process.on('unhandledRejection', (reason: unknown) => { - this.captureException(reason, 'process'); - }); - - this.logger.log('Error tracking placeholder integration initialized'); - } - - private readonly auditLog: AuditLogEntry[] = []; - - captureAuditLog(action: string, actor: string, resourceType: string, resourceId: string, details: Record = {}): AuditLogEntry { - const entry: AuditLogEntry = { action, actor, resourceType, resourceId, details, timestamp: new Date().toISOString() }; - this.auditLog.push(entry); - this.logger.log(`Audit: ${actor} ${action} ${resourceType} ${resourceId}`); - return entry; - } - - getAuditLog(resourceType?: string, resourceId?: string): AuditLogEntry[] { - let entries = this.auditLog; - if (resourceType) entries = entries.filter((e) => e.resourceType === resourceType); - if (resourceId) entries = entries.filter((e) => e.resourceId === resourceId); - return entries; - } - - captureException(error: Error | unknown, context?: string): ErrorTrackingReport { - const message = error instanceof Error ? error.message : String(error); - const stack = error instanceof Error ? error.stack : undefined; - - const report: ErrorTrackingReport = { - source: 'placeholder', - context, - message, - stack, - timestamp: new Date().toISOString(), - }; - - this.logger.warn(`Captured error report for ${context ?? 'unknown'}: ${message}`); - - return report; - } -} diff --git a/BackendAcademy/src/logging/logger.service.ts b/BackendAcademy/src/logging/logger.service.ts deleted file mode 100644 index 78fbd46a39..0000000000 --- a/BackendAcademy/src/logging/logger.service.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { Injectable, LoggerService, Scope } from '@nestjs/common'; -import { AsyncLocalStorage } from 'async_hooks'; - -export interface CorrelationContext { - correlationId: string; - userId?: string; - requestId?: string; - tenantId?: string; -} - -export interface LogEntry { - level: string; - message: string; - context?: string; - correlationId?: string; - userId?: string; - requestId?: string; - tenantId?: string; - timestamp: Date; - metadata?: Record; -} - -@Injectable({ scope: Scope.DEFAULT }) -export class CorrelationLoggerService implements LoggerService { - private static readonly asyncLocalStorage = new AsyncLocalStorage(); - private readonly logs: LogEntry[] = []; - - static run(context: CorrelationContext, fn: () => T | Promise): T | Promise { - return CorrelationLoggerService.asyncLocalStorage.run(context, fn); - } - - static getCorrelationId(): string | undefined { - return CorrelationLoggerService.asyncLocalStorage.getStore()?.correlationId; - } - - static getContext(): CorrelationContext | undefined { - return CorrelationLoggerService.asyncLocalStorage.getStore(); - } - - log(message: string, context?: string): void { - this.write('log', message, context); - } - - error(message: string, trace?: string, context?: string): void { - this.write('error', message, context, trace); - } - - warn(message: string, context?: string): void { - this.write('warn', message, context); - } - - debug(message: string, context?: string): void { - this.write('debug', message, context); - } - - verbose(message: string, context?: string): void { - this.write('verbose', message, context); - } - - getLogs(): LogEntry[] { - return [...this.logs]; - } - - getLogsByCorrelationId(correlationId: string): LogEntry[] { - return this.logs.filter((log) => log.correlationId === correlationId); - } - - clearLogs(): void { - this.logs.length = 0; - } - - private write(level: string, message: string, context?: string, trace?: string): void { - const store = CorrelationLoggerService.asyncLocalStorage.getStore(); - const entry: LogEntry = { - level, - message: trace ? `${message} - ${trace}` : message, - context, - correlationId: store?.correlationId, - userId: store?.userId, - requestId: store?.requestId, - tenantId: store?.tenantId, - timestamp: new Date(), - }; - this.logs.push(entry); - } -} \ No newline at end of file diff --git a/BackendAcademy/src/logging/logging.module.ts b/BackendAcademy/src/logging/logging.module.ts deleted file mode 100644 index 7ea44e624e..0000000000 --- a/BackendAcademy/src/logging/logging.module.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Module } from '@nestjs/common'; -import { ErrorTrackingService } from './error-tracking.service'; -import { CorrelationLoggerService } from './logger.service'; - -@Module({ - providers: [ErrorTrackingService, CorrelationLoggerService], - exports: [ErrorTrackingService, CorrelationLoggerService], -}) -export class LoggingModule {} \ No newline at end of file diff --git a/BackendAcademy/src/main.ts b/BackendAcademy/src/main.ts index bbee144190..6a0d3c0c31 100644 --- a/BackendAcademy/src/main.ts +++ b/BackendAcademy/src/main.ts @@ -1,112 +1,43 @@ import { NestFactory } from '@nestjs/core'; -import { NestExpressApplication } from '@nestjs/platform-express'; -import { AppModule } from './app.module'; -import { Logger, VersioningType } from '@nestjs/common'; +import { ValidationPipe, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; +import { NestExpressApplication } from '@nestjs/platform-express'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import helmet from 'helmet'; -import { createValidationPipe } from './common/validation.pipe'; -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import * as envConfig from './config/env.schema'; - -const validationSchema = (envConfig as any).validationSchema ?? (envConfig as any).default; - -if (!validationSchema || typeof validationSchema.validate !== 'function') { - throw new Error('Missing or invalid environment validation schema in src/config/env.schema.ts'); -} - -function validateEnv(): Record { - const { error, value } = validationSchema.validate(process.env, { - abortEarly: false, - allowUnknown: true, - convert: true, - }); - if (error) { - const details = error.details.map(detail => detail.message).join('; '); - throw new Error(`Invalid environment variables: ${details}`); - } - return value as Record; -} +import { AppModule } from './app.module'; async function bootstrap() { - const validatedEnv = validateEnv(); - for (const [key, val] of Object.entries(validatedEnv)) { - process.env[key] = String(val); - } - - // #662: `rawBody: true` keeps the unparsed request bytes available on - // `req.rawBody` so webhook signatures can be verified against exactly what - // the provider sent, before any JSON parsing/normalization happens. - const app = await NestFactory.create(AppModule, { - rawBody: true, - }); const logger = new Logger('Bootstrap'); - - const config = app.get(ConfigService); - - // Security: refuse to start in production with a missing or known default - // JWT secret (e.g. the auth module's fallback 'changeme'). The error must - // not disclose the secret itself. - const nodeEnv = config.get('NODE_ENV', 'development'); - if (nodeEnv === 'production') { - const jwtSecret = config.get('JWT_SECRET'); - if (!jwtSecret || jwtSecret === 'changeme') { - throw new Error( - 'JWT_SECRET must be set to a secure value when NODE_ENV=production.', - ); - } - } - - // Graceful shutdown support - app.enableShutdownHooks(); - - // Override the internal config with the already-coerced environment values. - for (const [key, val] of Object.entries(validatedEnv)) { - config.set(key, val); - } + const app = await NestFactory.create(AppModule); app.use(helmet()); - - app.enableCors({ - origin: config.get('CORS_ORIGIN', '*'), - methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], - credentials: true, - }); - - app.enableVersioning({ - type: VersioningType.URI, - prefix: 'api/v', - defaultVersion: '1', - }); - - // Shared options (src/common/validation.pipe.ts) guarantee nested DSos - // and arrays are validated — and malformed payloads rejected — the same - // way in every controller. - app.useGlobalPipes(createValidationPipe()); - - const swaggerConfig = new DocumentBuilder() - .setTitle('RustAcademy API') - .setDescription('The RustAcademy Backend API') - .setVersion('1.0') - .addBearerAuth() - .build(); - const document = SwaggerModule.createDocument(app, swaggerConfig); - SwaggerModule.setup('api/docs', app, document); - - const staticDir = path.resolve( - config.get('ASSETS_STATIC_DIR', './public'), + app.enableCors(); + app.setGlobalPrefix('api', { exclude: ['health'] }); + + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true, + forbidNonWhitelisted: true, + }), ); - try { - fs.mkdirSync(staticDir, { recursive: true }); - app.useStaticAssets(staticDir, { prefix: '/static/' }); - logger.log(`Static assets served from ${staticDir} at /static/`); - } catch (err) { - logger.warn(`Failed to mount static asset directory ${staticDir}: ${err instanceof Error ? err.message : String(err)}`); + + const configService = app.get(ConfigService); + const swaggerEnabled = configService.get('SWAGGER_ENABLED', 'true') === 'true'; + if (swaggerEnabled) { + const config = new DocumentBuilder() + .setTitle('RustAcademy BackendAcademy API') + .setDescription('NestJS backend API for RustAcademy — learn Rust, earn XLM, build Web3.') + .setVersion('0.1') + .addBearerAuth() + .build(); + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup('api/docs', app, document); } - const port = config.get('PORT', 3000); + const port = Number(configService.get('PORT', '4001')); await app.listen(port); - logger.log(`Backend running on http://localhost:${port}`); + logger.log(`BackendAcademy API listening on http://localhost:${port}/api`); } -bootstrap(); \ No newline at end of file + +bootstrap(); diff --git a/BackendAcademy/src/modify_auth.js b/BackendAcademy/src/modify_auth.js deleted file mode 100644 index dfa97f6b24..0000000000 --- a/BackendAcademy/src/modify_auth.js +++ /dev/null @@ -1,16 +0,0 @@ -const fs = require('fs'); -let code = fs.readFileSync('auth/auth-session.service.ts', 'utf8'); - -code = code.replace( - "import { JwtService } from '@nestjs/jwt';", - "import { JwtService } from '@nestjs/jwt';\nimport { AuditLogService } from '../audit/audit.service';" -); - -code = code.replace( - "constructor(\n private readonly jwtService: JwtService,", - "constructor(\n private readonly jwtService: JwtService,\n private readonly auditService: AuditLogService," -); - -// We need to instrument the methods to log audit events. -// Instead of complex regex, let's just write another script or manually do it. -fs.writeFileSync('auth/auth-session.service.ts', code); diff --git a/BackendAcademy/src/monitoring/metrics.controller.ts b/BackendAcademy/src/monitoring/metrics.controller.ts deleted file mode 100644 index fe0ae76bce..0000000000 --- a/BackendAcademy/src/monitoring/metrics.controller.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Controller, Get, VERSION_NEUTRAL, Version, Res } from '@nestjs/common'; -import { PrometheusController } from '@willsoto/nestjs-prometheus'; -import type { Response } from 'express'; - -/** - * Custom Prometheus endpoint controller. - * - * Extends the library's {@link PrometheusController} so that: - * 1. The `path` metadata set by `PrometheusModule.register({ path })` - * still applies at runtime. - * 2. The global URI versioning configured in `main.ts` - * (`app.enableVersioning({ type: URI, prefix: 'api/v' })`) is bypassed - * via {@link VERSION_NEUTRAL} on the `index` method, keeping the route - * at `/metrics` rather than `/api/v/metrics`. This matches Prometheus - * best-practice (no URI versioning on the scrape target) so any - * off-the-shelf Prometheus / Grafana installation works out of the box. - * - * NOTE: `@Version` is *only* applied to the method (not the class) because - * `@nestjs/common@10.4.22`'s `Version` factory dereferences `descriptor.value`, - * which is `undefined` when the decorator targets a class and crashes at - * runtime. Pinning it to the method keeps the call valid in TS and runtime. - * - * The `/metrics` endpoint is intentionally unauthenticated; production - * deployments must restrict access at the infrastructure layer (firewall, - * scrape-only VPC, or service-mesh allowlist). - */ -@Controller() -export class MetricsController extends PrometheusController { - @Get() - @Version(VERSION_NEUTRAL) - async index(@Res({ passthrough: true }) response: Response): Promise { - return super.index(response); - } -} diff --git a/BackendAcademy/src/monitoring/metrics.service.ts b/BackendAcademy/src/monitoring/metrics.service.ts deleted file mode 100644 index 82914f81dd..0000000000 --- a/BackendAcademy/src/monitoring/metrics.service.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { isFeatureEnabled } from '../config/env.schema'; - -interface MetricEntry { - name: string; - value: number; - timestamp: Date; - labels: Record; - correlationId?: string; -} - -interface CronHealthStatus { - name: string; - expression: string; - isValid: boolean; - lastRun?: Date; - nextExpectedRun?: Date; - status: 'healthy' | 'warning' | 'error'; - error?: string; -} - -/** - * Metrics service tracking application-level metrics including - * contract registry (#393), event replay (#394), feature flag - * state (#395), streaming disconnects (#373), and reward - * redemptions (#363). - */ -@Injectable() -export class MetricsService implements OnModuleInit { - private readonly logger = new Logger(MetricsService.name); - private readonly metrics = new Map(); - private readonly cronHealth = new Map(); - private readonly requestCounts = new Map(); - private readonly errorCounts = new Map(); - - constructor(private readonly configService: ConfigService) {} - - onModuleInit(): void { - this.registerCronHealthFromConfig(); - this.recordFeatureFlagMetrics(); - this.logger.log('MetricsService initialized'); - } - - // ────────────────────────────────────────────────────────────────── - // Counter & gauge operations - // ────────────────────────────────────────────────────────────────── - - incrementCounter(name: string, value = 1, labels: Record = {}): void { - const existing = this.metrics.get(name); - if (existing) { - existing.value += value; - existing.timestamp = new Date(); - existing.labels = { ...existing.labels, ...labels }; - } else { - this.metrics.set(name, { - name, - value, - timestamp: new Date(), - labels, - }); - } - this.logger.debug(`Metric "${name}" incremented to ${this.metrics.get(name)?.value}`); - } - - setGauge(name: string, value: number, labels: Record = {}): void { - this.metrics.set(name, { - name, - value, - timestamp: new Date(), - labels, - }); - } - - recordLatency(endpoint: string, latencyMs: number): void { - const key = `latency:${endpoint}`; - const existing = this.metrics.get(key); - if (existing) { - existing.value = existing.value * 0.9 + latencyMs * 0.1; - existing.timestamp = new Date(); - } else { - this.metrics.set(key, { - name: key, - value: latencyMs, - timestamp: new Date(), - labels: { endpoint }, - }); - } - } - - recordDomainEvent(eventType: string, source: string): void { - this.incrementCounter('domain_events_total', 1, { event_type: eventType, source }); - } - - recordErrorEvent(source: string, reason: string): void { - this.incrementCounter('error_events_total', 1, { source, reason }); - } - - getAllMetrics(): MetricEntry[] { - return Array.from(this.metrics.values()); - } - - trackRequest(endpoint: string): void { - const count = this.requestCounts.get(endpoint) || 0; - this.requestCounts.set(endpoint, count + 1); - this.incrementCounter('requests_total', 1, { endpoint }); - } - - getRequestStats(): Array<{ endpoint: string; count: number }> { - return Array.from(this.requestCounts.entries()).map(([endpoint, count]) => ({ - endpoint, - count, - })); - } - - // ────────────────────────────────────────────────────────────────── - // Cron health - // ────────────────────────────────────────────────────────────────── - - private registerCronHealthFromConfig(): void { - const entries: Array<{ name: string; key: string }> = [ - { name: 'cleanup', key: 'CRON_CLEANUP_SCHEDULE' }, - { name: 'analytics', key: 'CRON_ANALYTICS_SCHEDULE' }, - { name: 'notifications', key: 'CRON_NOTIFICATIONS_SCHEDULE' }, - // #394: Replay cron health tracking - { name: 'contract_replay', key: 'CRON_CONTRACT_REPLAY_SCHEDULE' }, - ]; - - for (const entry of entries) { - const expression = this.configService.get(entry.key) || ''; - this.cronHealth.set(entry.name, { - name: entry.name, - expression, - isValid: expression.length > 0, - status: expression ? 'healthy' : 'warning', - }); - } - } - - getCronHealth(): CronHealthStatus[] { - return Array.from(this.cronHealth.values()); - } - - recordCronRun(name: string): void { - const entry = this.cronHealth.get(name); - if (entry) { - entry.lastRun = new Date(); - entry.status = 'healthy'; - } - } - - recordCronError(name: string, error: string): void { - const entry = this.cronHealth.get(name); - if (entry) { - entry.status = 'error'; - entry.error = error; - } - this.incrementCounter('cron_errors_total', 1, { job: name }); - } - - // ────────────────────────────────────────────────────────────────── - // #395: Feature flag state tracking - // ────────────────────────────────────────────────────────────────── - - private recordFeatureFlagMetrics(): void { - const flags: Array<{ name: string; key: string }> = [ - { name: 'contract_ingestion_enabled', key: 'CONTRACT_INGESTION_ENABLED' }, - { name: 'contract_registry_require_schema', key: 'CONTRACT_REGISTRY_REQUIRE_SCHEMA' }, - { name: 'contract_event_replay_enabled', key: 'CONTRACT_EVENT_REPLAY_ENABLED' }, - ]; - - for (const flag of flags) { - const value = this.configService.get(flag.key); - const enabled = isFeatureEnabled(value); - this.setGauge(`feature_flag:${flag.name}`, enabled ? 1 : 0, { - flag: flag.name, - rawValue: value ?? 'undefined', - }); - this.logger.debug( - `Feature flag "${flag.name}" = ${enabled ? 'ENABLED' : 'DISABLED'} (raw: "${value ?? 'undefined'}")`, - ); - } - - this.incrementCounter('contracts_metrics_initialized', 1, {}); - } -} diff --git a/BackendAcademy/src/monitoring/monitoring.metrics.ts b/BackendAcademy/src/monitoring/monitoring.metrics.ts deleted file mode 100644 index 55ac326203..0000000000 --- a/BackendAcademy/src/monitoring/monitoring.metrics.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { makeCounterProvider, makeHistogramProvider, makeGaugeProvider } from '@willsoto/nestjs-prometheus'; - -// ────────────────────────────────────────────────────────────────── -// Counter metrics -// ────────────────────────────────────────────────────────────────── - -export const HTTP_REQUESTS_METRIC = 'app_http_requests_total'; -export const DOMAIN_EVENTS_METRIC = 'app_domain_events_total'; -export const ERROR_EVENTS_METRIC = 'app_error_events_total'; -export const SERVICE_ERRORS_METRIC = 'app_service_errors_total'; -export const DOMAIN_OUTCOMES_METRIC = 'app_domain_outcomes_total'; - -export const httpRequestsCounterProvider = makeCounterProvider({ - name: HTTP_REQUESTS_METRIC, - help: 'Total number of HTTP requests received by the application', - labelNames: ['method', 'route', 'status_code', 'tenant_id', 'request_id'], -}); - -export const domainEventsCounterProvider = makeCounterProvider({ - name: DOMAIN_EVENTS_METRIC, - help: 'Total number of domain/business events emitted by the application', - labelNames: ['event_type', 'source', 'tenant_id', 'request_id'], -}); - -export const errorEventsCounterProvider = makeCounterProvider({ - name: ERROR_EVENTS_METRIC, - help: 'Total number of error events emitted by the application', - labelNames: ['source', 'reason', 'tenant_id', 'request_id'], -}); - -/** - * Per-service error counter. Tracks error rates broken down by - * service name (database, redis, ai, grading, payment, notification) - * and error type (timeout, connection, validation, etc.). - */ -export const serviceErrorsCounterProvider = makeCounterProvider({ - name: SERVICE_ERRORS_METRIC, - help: 'Total number of errors per service', - labelNames: ['service', 'error_type'], -}); - -/** - * Domain outcome counter. Tracks business-relevant outcomes such as - * course completions, payment successes/failures, and notification - * delivery results. - */ -export const domainOutcomesCounterProvider = makeCounterProvider({ - name: DOMAIN_OUTCOMES_METRIC, - help: 'Total number of domain outcome events', - labelNames: ['outcome', 'service', 'status'], -}); - -// ────────────────────────────────────────────────────────────────── -// Histogram metrics (latency) -// ────────────────────────────────────────────────────────────────── - -export const HTTP_LATENCY_METRIC = 'app_http_request_duration_seconds'; -export const DATABASE_LATENCY_METRIC = 'app_database_query_duration_seconds'; -export const REDIS_LATENCY_METRIC = 'app_redis_operation_duration_seconds'; -export const AI_LATENCY_METRIC = 'app_ai_provider_duration_seconds'; -export const GRADING_LATENCY_METRIC = 'app_grading_operation_duration_seconds'; -export const PAYMENT_LATENCY_METRIC = 'app_payment_operation_duration_seconds'; -export const NOTIFICATION_LATENCY_METRIC = 'app_notification_operation_duration_seconds'; - -export const httpLatencyHistogramProvider = makeHistogramProvider({ - name: HTTP_LATENCY_METRIC, - help: 'HTTP request latency in seconds', - labelNames: ['method', 'route', 'status_code'], - buckets: [0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10], -}); - -export const databaseLatencyHistogramProvider = makeHistogramProvider({ - name: DATABASE_LATENCY_METRIC, - help: 'Database query latency in seconds', - labelNames: ['operation', 'table'], - buckets: [0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1], -}); - -export const redisLatencyHistogramProvider = makeHistogramProvider({ - name: REDIS_LATENCY_METRIC, - help: 'Redis operation latency in seconds', - labelNames: ['operation'], - buckets: [0.0005, 0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1], -}); - -export const aiLatencyHistogramProvider = makeHistogramProvider({ - name: AI_LATENCY_METRIC, - help: 'AI provider request latency in seconds', - labelNames: ['provider', 'operation'], - buckets: [0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30], -}); - -export const gradingLatencyHistogramProvider = makeHistogramProvider({ - name: GRADING_LATENCY_METRIC, - help: 'Grading operation latency in seconds', - labelNames: ['operation'], - buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5], -}); - -export const paymentLatencyHistogramProvider = makeHistogramProvider({ - name: PAYMENT_LATENCY_METRIC, - help: 'Payment operation latency in seconds', - labelNames: ['operation', 'provider'], - buckets: [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10], -}); - -export const notificationLatencyHistogramProvider = makeHistogramProvider({ - name: NOTIFICATION_LATENCY_METRIC, - help: 'Notification delivery latency in seconds', - labelNames: ['channel', 'operation'], - buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5], -}); - -// ────────────────────────────────────────────────────────────────── -// Gauge metrics (queue depth / saturation) -// ────────────────────────────────────────────────────────────────── - -export const JOBS_QUEUE_DEPTH_METRIC = 'app_jobs_queue_depth'; -export const DEAD_LETTER_QUEUE_DEPTH_METRIC = 'app_dead_letter_queue_depth'; - -export const jobsQueueDepthGaugeProvider = makeGaugeProvider({ - name: JOBS_QUEUE_DEPTH_METRIC, - help: 'Current depth of the jobs queue (pending + retrying)', - labelNames: ['queue'], -}); - -export const deadLetterQueueDepthGaugeProvider = makeGaugeProvider({ - name: DEAD_LETTER_QUEUE_DEPTH_METRIC, - help: 'Current depth of the dead-letter queue (exhausted retries)', - labelNames: ['queue'], -}); - diff --git a/BackendAcademy/src/monitoring/monitoring.module.spec.ts b/BackendAcademy/src/monitoring/monitoring.module.spec.ts deleted file mode 100644 index 4c2f6a80a4..0000000000 --- a/BackendAcademy/src/monitoring/monitoring.module.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { PrometheusModule } from '@willsoto/nestjs-prometheus'; -import { MonitoringModule } from './monitoring.module'; -import { MonitoringService } from './monitoring.service'; - -describe('MonitoringModule', () => { - it('compiles and exposes MonitoringService when imported', async () => { - const moduleRef: TestingModule = await Test.createTestingModule({ - imports: [MonitoringModule], - }).compile(); - - expect(moduleRef.get(MonitoringService)).toBeInstanceOf(MonitoringService); - }); - - it('still exposes MonitoringService alongside an additional PrometheusModule registration', async () => { - const moduleRef: TestingModule = await Test.createTestingModule({ - imports: [ - MonitoringModule, - PrometheusModule.register({ defaultMetrics: { enabled: false } }), - ], - }).compile(); - - expect(moduleRef.get(MonitoringService)).toBeInstanceOf(MonitoringService); - }); -}); diff --git a/BackendAcademy/src/monitoring/monitoring.module.ts b/BackendAcademy/src/monitoring/monitoring.module.ts deleted file mode 100644 index 4c52e1e056..0000000000 --- a/BackendAcademy/src/monitoring/monitoring.module.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { Module } from '@nestjs/common'; -import { PrometheusModule } from '@willsoto/nestjs-prometheus'; -import { MetricsService } from './metrics.service'; -import { - httpRequestsCounterProvider, - domainEventsCounterProvider, - errorEventsCounterProvider, - serviceErrorsCounterProvider, - domainOutcomesCounterProvider, - httpLatencyHistogramProvider, - databaseLatencyHistogramProvider, - redisLatencyHistogramProvider, - aiLatencyHistogramProvider, - gradingLatencyHistogramProvider, - paymentLatencyHistogramProvider, - notificationLatencyHistogramProvider, - jobsQueueDepthGaugeProvider, - deadLetterQueueDepthGaugeProvider, -} from './monitoring.metrics'; - -@Module({ - imports: [ - PrometheusModule.register({ defaultMetrics: { enabled: false } }), - ], - providers: [ - MetricsService, - httpRequestsCounterProvider, - domainEventsCounterProvider, - errorEventsCounterProvider, - serviceErrorsCounterProvider, - domainOutcomesCounterProvider, - httpLatencyHistogramProvider, - databaseLatencyHistogramProvider, - redisLatencyHistogramProvider, - aiLatencyHistogramProvider, - gradingLatencyHistogramProvider, - paymentLatencyHistogramProvider, - notificationLatencyHistogramProvider, - jobsQueueDepthGaugeProvider, - deadLetterQueueDepthGaugeProvider, - ], - exports: [MetricsService], -}) -export class MonitoringModule {} diff --git a/BackendAcademy/src/monitoring/monitoring.service.spec.ts b/BackendAcademy/src/monitoring/monitoring.service.spec.ts deleted file mode 100644 index 1a0a42170c..0000000000 --- a/BackendAcademy/src/monitoring/monitoring.service.spec.ts +++ /dev/null @@ -1,290 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import type { Counter, Gauge, Histogram } from 'prom-client'; -import { getToken } from '@willsoto/nestjs-prometheus'; -import { - DOMAIN_EVENTS_METRIC, - ERROR_EVENTS_METRIC, - HTTP_REQUESTS_METRIC, - SERVICE_ERRORS_METRIC, - DOMAIN_OUTCOMES_METRIC, - HTTP_LATENCY_METRIC, - DATABASE_LATENCY_METRIC, - REDIS_LATENCY_METRIC, - AI_LATENCY_METRIC, - GRADING_LATENCY_METRIC, - PAYMENT_LATENCY_METRIC, - NOTIFICATION_LATENCY_METRIC, - JOBS_QUEUE_DEPTH_METRIC, - DEAD_LETTER_QUEUE_DEPTH_METRIC, -} from './monitoring.metrics'; -import { MonitoringService } from './monitoring.service'; - -/** - * @willsoto/nestjs-prometheus resolves @InjectMetric(name) to - * Inject(getToken(name)) where getToken returns `PROM_METRIC_${name.toUpperCase()}`. - * Tests must provide their mocks under the same tokens. - */ -const HTTP_REQUESTS_TOKEN = getToken(HTTP_REQUESTS_METRIC); -const DOMAIN_EVENTS_TOKEN = getToken(DOMAIN_EVENTS_METRIC); -const ERROR_EVENTS_TOKEN = getToken(ERROR_EVENTS_METRIC); -const SERVICE_ERRORS_TOKEN = getToken(SERVICE_ERRORS_METRIC); -const DOMAIN_OUTCOMES_TOKEN = getToken(DOMAIN_OUTCOMES_METRIC); -const HTTP_LATENCY_TOKEN = getToken(HTTP_LATENCY_METRIC); -const DATABASE_LATENCY_TOKEN = getToken(DATABASE_LATENCY_METRIC); -const REDIS_LATENCY_TOKEN = getToken(REDIS_LATENCY_METRIC); -const AI_LATENCY_TOKEN = getToken(AI_LATENCY_METRIC); -const GRADING_LATENCY_TOKEN = getToken(GRADING_LATENCY_METRIC); -const PAYMENT_LATENCY_TOKEN = getToken(PAYMENT_LATENCY_METRIC); -const NOTIFICATION_LATENCY_TOKEN = getToken(NOTIFICATION_LATENCY_METRIC); -const JOBS_QUEUE_DEPTH_TOKEN = getToken(JOBS_QUEUE_DEPTH_METRIC); -const DEAD_LETTER_QUEUE_DEPTH_TOKEN = getToken(DEAD_LETTER_QUEUE_DEPTH_METRIC); - -describe('MonitoringService', () => { - let service: MonitoringService; - let httpRequests: Counter; - let domainEvents: Counter; - let errorEvents: Counter; - let serviceErrors: Counter; - let domainOutcomes: Counter; - let httpLatency: Histogram; - let databaseLatency: Histogram; - let redisLatency: Histogram; - let aiLatency: Histogram; - let gradingLatency: Histogram; - let paymentLatency: Histogram; - let notificationLatency: Histogram; - let jobsQueueDepth: Gauge; - let deadLetterQueueDepth: Gauge; - - beforeEach(async () => { - const httpRequestsMock = { inc: jest.fn() } as unknown as Counter; - const domainEventsMock = { inc: jest.fn() } as unknown as Counter; - const errorEventsMock = { inc: jest.fn() } as unknown as Counter; - const serviceErrorsMock = { inc: jest.fn() } as unknown as Counter; - const domainOutcomesMock = { inc: jest.fn() } as unknown as Counter; - const httpLatencyMock = { observe: jest.fn() } as unknown as Histogram; - const databaseLatencyMock = { observe: jest.fn() } as unknown as Histogram; - const redisLatencyMock = { observe: jest.fn() } as unknown as Histogram; - const aiLatencyMock = { observe: jest.fn() } as unknown as Histogram; - const gradingLatencyMock = { observe: jest.fn() } as unknown as Histogram; - const paymentLatencyMock = { observe: jest.fn() } as unknown as Histogram; - const notificationLatencyMock = { observe: jest.fn() } as unknown as Histogram; - const jobsQueueDepthMock = { set: jest.fn() } as unknown as Gauge; - const deadLetterQueueDepthMock = { set: jest.fn() } as unknown as Gauge; - - const moduleRef: TestingModule = await Test.createTestingModule({ - providers: [ - MonitoringService, - { provide: HTTP_REQUESTS_TOKEN, useValue: httpRequestsMock }, - { provide: DOMAIN_EVENTS_TOKEN, useValue: domainEventsMock }, - { provide: ERROR_EVENTS_TOKEN, useValue: errorEventsMock }, - { provide: SERVICE_ERRORS_TOKEN, useValue: serviceErrorsMock }, - { provide: DOMAIN_OUTCOMES_TOKEN, useValue: domainOutcomesMock }, - { provide: HTTP_LATENCY_TOKEN, useValue: httpLatencyMock }, - { provide: DATABASE_LATENCY_TOKEN, useValue: databaseLatencyMock }, - { provide: REDIS_LATENCY_TOKEN, useValue: redisLatencyMock }, - { provide: AI_LATENCY_TOKEN, useValue: aiLatencyMock }, - { provide: GRADING_LATENCY_TOKEN, useValue: gradingLatencyMock }, - { provide: PAYMENT_LATENCY_TOKEN, useValue: paymentLatencyMock }, - { provide: NOTIFICATION_LATENCY_TOKEN, useValue: notificationLatencyMock }, - { provide: JOBS_QUEUE_DEPTH_TOKEN, useValue: jobsQueueDepthMock }, - { provide: DEAD_LETTER_QUEUE_DEPTH_TOKEN, useValue: deadLetterQueueDepthMock }, - ], - }).compile(); - - service = moduleRef.get(MonitoringService); - httpRequests = httpRequestsMock; - domainEvents = domainEventsMock; - errorEvents = errorEventsMock; - serviceErrors = serviceErrorsMock; - domainOutcomes = domainOutcomesMock; - httpLatency = httpLatencyMock; - databaseLatency = databaseLatencyMock; - redisLatency = redisLatencyMock; - aiLatency = aiLatencyMock; - gradingLatency = gradingLatencyMock; - paymentLatency = paymentLatencyMock; - notificationLatency = notificationLatencyMock; - jobsQueueDepth = jobsQueueDepthMock; - deadLetterQueueDepth = deadLetterQueueDepthMock; - }); - - describe('recordHttpRequest', () => { - it('increments the counter with a route prefixed by /', () => { - service.recordHttpRequest('GET', 'health', 200); - expect(httpRequests.inc).toHaveBeenCalledTimes(1); - expect(httpRequests.inc).toHaveBeenCalledWith({ - method: 'GET', - route: '/health', - status_code: '200', - tenant_id: 'unknown', - request_id: 'unknown', - }); - }); - - it('does not double-prefix routes that already start with /', () => { - service.recordHttpRequest('POST', '/social/posts', 201); - expect(httpRequests.inc).toHaveBeenCalledWith({ - method: 'POST', - route: '/social/posts', - status_code: '201', - tenant_id: 'unknown', - request_id: 'unknown', - }); - }); - - it('uses / for an empty route to keep cardinality bounded', () => { - service.recordHttpRequest('GET', '', 204); - expect(httpRequests.inc).toHaveBeenCalledWith({ - method: 'GET', - route: '/', - status_code: '204', - tenant_id: 'unknown', - request_id: 'unknown', - }); - }); - }); - - describe('recordDomainEvent', () => { - it('increments with the event_type and source labels', () => { - service.recordDomainEvent('badge_awarded', 'badges'); - expect(domainEvents.inc).toHaveBeenCalledWith({ - event_type: 'badge_awarded', - source: 'badges', - tenant_id: 'unknown', - request_id: 'unknown', - }); - }); - }); - - describe('recordError', () => { - it('increments with the source and reason labels', () => { - service.recordError('submissions', 'grading_failed'); - expect(errorEvents.inc).toHaveBeenCalledWith({ - source: 'submissions', - reason: 'grading_failed', - tenant_id: 'unknown', - request_id: 'unknown', - }); - }); - }); - - describe('recordServiceError', () => { - it('increments the service errors counter with service and error_type', () => { - service.recordServiceError('database', 'timeout'); - expect(serviceErrors.inc).toHaveBeenCalledWith({ - service: 'database', - error_type: 'timeout', - }); - }); - - it('works for all service types', () => { - service.recordServiceError('redis', 'connection'); - expect(serviceErrors.inc).toHaveBeenCalledWith({ - service: 'redis', - error_type: 'connection', - }); - }); - }); - - describe('recordHttpLatency', () => { - it('observes latency in seconds (ms / 1000)', () => { - service.recordHttpLatency('GET', '/health', 200, 150); - expect(httpLatency.observe).toHaveBeenCalledWith( - { method: 'GET', route: '/health', status_code: '200' }, - 0.15, - ); - }); - }); - - describe('recordDatabaseLatency', () => { - it('observes latency with operation and table labels', () => { - service.recordDatabaseLatency('find', 'courses', 25); - expect(databaseLatency.observe).toHaveBeenCalledWith( - { operation: 'find', table: 'courses' }, - 0.025, - ); - }); - }); - - describe('recordRedisLatency', () => { - it('observes latency with operation label', () => { - service.recordRedisLatency('get', 5); - expect(redisLatency.observe).toHaveBeenCalledWith({ operation: 'get' }, 0.005); - }); - }); - - describe('recordAiLatency', () => { - it('observes latency with provider and operation labels', () => { - service.recordAiLatency('openai', 'chat', 2500); - expect(aiLatency.observe).toHaveBeenCalledWith( - { provider: 'openai', operation: 'chat' }, - 2.5, - ); - }); - }); - - describe('recordGradingLatency', () => { - it('observes latency with operation label', () => { - service.recordGradingLatency('grade_submission', 500); - expect(gradingLatency.observe).toHaveBeenCalledWith( - { operation: 'grade_submission' }, - 0.5, - ); - }); - }); - - describe('recordPaymentLatency', () => { - it('observes latency with operation and provider labels', () => { - service.recordPaymentLatency('create', 'stellar', 100); - expect(paymentLatency.observe).toHaveBeenCalledWith( - { operation: 'create', provider: 'stellar' }, - 0.1, - ); - }); - }); - - describe('recordNotificationLatency', () => { - it('observes latency with channel and operation labels', () => { - service.recordNotificationLatency('email', 'send', 200); - expect(notificationLatency.observe).toHaveBeenCalledWith( - { channel: 'email', operation: 'send' }, - 0.2, - ); - }); - }); - - describe('setJobsQueueDepth', () => { - it('sets the gauge with queue and depth values', () => { - service.setJobsQueueDepth('webhooks', 42); - expect(jobsQueueDepth.set).toHaveBeenCalledWith({ queue: 'webhooks' }, 42); - }); - }); - - describe('setDeadLetterQueueDepth', () => { - it('sets the gauge with queue and depth values', () => { - service.setDeadLetterQueueDepth('webhooks', 3); - expect(deadLetterQueueDepth.set).toHaveBeenCalledWith({ queue: 'webhooks' }, 3); - }); - }); - - describe('recordDomainOutcome', () => { - it('increments with outcome, service, and status labels', () => { - service.recordDomainOutcome('course_completed', 'courses', 'success'); - expect(domainOutcomes.inc).toHaveBeenCalledWith({ - outcome: 'course_completed', - service: 'courses', - status: 'success', - }); - }); - - it('tracks payment failure outcomes', () => { - service.recordDomainOutcome('payment_processed', 'payment', 'failure'); - expect(domainOutcomes.inc).toHaveBeenCalledWith({ - outcome: 'payment_processed', - service: 'payment', - status: 'failure', - }); - }); - }); -}); diff --git a/BackendAcademy/src/monitoring/monitoring.service.ts b/BackendAcademy/src/monitoring/monitoring.service.ts deleted file mode 100644 index 11e8b96351..0000000000 --- a/BackendAcademy/src/monitoring/monitoring.service.ts +++ /dev/null @@ -1,308 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { Counter, Gauge, Histogram } from 'prom-client'; -import { InjectMetric } from '@willsoto/nestjs-prometheus'; -import { - DOMAIN_EVENTS_METRIC, - ERROR_EVENTS_METRIC, - HTTP_REQUESTS_METRIC, - SERVICE_ERRORS_METRIC, - DOMAIN_OUTCOMES_METRIC, - HTTP_LATENCY_METRIC, - DATABASE_LATENCY_METRIC, - REDIS_LATENCY_METRIC, - AI_LATENCY_METRIC, - GRADING_LATENCY_METRIC, - PAYMENT_LATENCY_METRIC, - NOTIFICATION_LATENCY_METRIC, - JOBS_QUEUE_DEPTH_METRIC, - DEAD_LETTER_QUEUE_DEPTH_METRIC, -} from './monitoring.metrics'; -import { CorrelationLoggerService } from '../logging/logger.service'; - -/** - * Service-level metric names used by {@link SERVICE_ERRORS_METRIC}. - * Keeping these as a const enum-like block avoids typos in label values. - */ -export const MetricServiceNames = { - DATABASE: 'database', - REDIS: 'redis', - AI: 'ai', - GRADING: 'grading', - PAYMENT: 'payment', - NOTIFICATION: 'notification', - HTTP: 'http', -} as const; - -export type MetricServiceName = (typeof MetricServiceNames)[keyof typeof MetricServiceNames]; - -/** - * Error type labels for {@link SERVICE_ERRORS_METRIC}. - */ -export const MetricErrorTypes = { - TIMEOUT: 'timeout', - CONNECTION: 'connection', - VALIDATION: 'validation', - RATE_LIMIT: 'rate_limit', - UNKNOWN: 'unknown', -} as const; - -export type MetricErrorType = (typeof MetricErrorTypes)[keyof typeof MetricErrorTypes]; - -/** - * Thin wrapper around the Prometheus counters registered by - * {@link MonitoringModule}. Other modules inject this service to record - * business-relevant metrics without directly knowing about `prom-client`. - * - * #377: All counters now include `tenant_id` and `request_id` labels - * so monitoring spans carry full request context for multi-tenant - * troubleshooting. - */ -@Injectable() -export class MonitoringService { - /** Internal counters for session & API key events (not Prom-registered). */ - private sessionRevocations = 0; - private apiKeyCreations = 0; - private apiKeyRevocations = 0; - private apiKeyAnomalies = 0; - - private readonly logger = new Logger(MonitoringService.name); - - constructor( - @InjectMetric(HTTP_REQUESTS_METRIC) - private readonly httpRequests: Counter, - @InjectMetric(DOMAIN_EVENTS_METRIC) - private readonly domainEvents: Counter, - @InjectMetric(ERROR_EVENTS_METRIC) - private readonly errorEvents: Counter, - @InjectMetric(SERVICE_ERRORS_METRIC) - private readonly serviceErrors: Counter, - @InjectMetric(DOMAIN_OUTCOMES_METRIC) - private readonly domainOutcomes: Counter, - @InjectMetric(HTTP_LATENCY_METRIC) - private readonly httpLatency: Histogram, - @InjectMetric(DATABASE_LATENCY_METRIC) - private readonly databaseLatency: Histogram, - @InjectMetric(REDIS_LATENCY_METRIC) - private readonly redisLatency: Histogram, - @InjectMetric(AI_LATENCY_METRIC) - private readonly aiLatency: Histogram, - @InjectMetric(GRADING_LATENCY_METRIC) - private readonly gradingLatency: Histogram, - @InjectMetric(PAYMENT_LATENCY_METRIC) - private readonly paymentLatency: Histogram, - @InjectMetric(NOTIFICATION_LATENCY_METRIC) - private readonly notificationLatency: Histogram, - @InjectMetric(JOBS_QUEUE_DEPTH_METRIC) - private readonly jobsQueueDepth: Gauge, - @InjectMetric(DEAD_LETTER_QUEUE_DEPTH_METRIC) - private readonly deadLetterQueueDepth: Gauge, - ) {} - - /** - * #377: Extracts tenant and request IDs from the current AsyncLocalStorage - * context so every metric span carries request identity. - */ - private getRequestContext(): { tenant_id: string; request_id: string } { - const ctx = CorrelationLoggerService.getContext(); - return { - tenant_id: ctx?.tenantId ?? 'unknown', - request_id: ctx?.requestId ?? 'unknown', - }; - } - - /** - * Record a single HTTP request. Routes are normalized to always start with - * `/` so that label cardinality stays bounded. - */ - recordHttpRequest(method: string, route: string, statusCode: number): void { - const normalizedRoute = normalizeRoute(route); - this.httpRequests.inc({ - method, - route: normalizedRoute, - status_code: statusCode.toString(), - ...this.getRequestContext(), - }); - } - - /** - * Record HTTP request latency in the histogram. - */ - recordHttpLatency(method: string, route: string, statusCode: number, durationMs: number): void { - this.httpLatency.observe( - { method, route: normalizeRoute(route), status_code: statusCode.toString() }, - durationMs / 1000, - ); - } - - /** - * Record a domain/business event (e.g. `badge_awarded` from the badges - * module). The `source` label identifies the originating module. - */ - recordDomainEvent(eventType: string, source: string): void { - this.domainEvents.inc({ event_type: eventType, source, ...this.getRequestContext() }); - } - - /** - * Record an error event. Use this sparingly to avoid leaking sensitive - * details into metrics; label values must be stable and bounded. - */ - recordError(source: string, reason: string): void { - this.errorEvents.inc({ source, reason, ...this.getRequestContext() }); - } - - /** - * Record a session revocation event. - */ - recordSessionRevocation(userId: string, reason: string): void { - this.sessionRevocations++; - this.domainEvents.inc({ - event_type: 'session_revoked', - source: 'auth', - ...this.getRequestContext(), - }); - } - - /** - * Record an API key creation event. - */ - recordApiKeyCreation(userId: string): void { - this.apiKeyCreations++; - this.domainEvents.inc({ - event_type: 'api_key_created', - source: 'security', - ...this.getRequestContext(), - }); - } - - /** - * Record an API key revocation event. - */ - recordApiKeyRevocation(userId: string, reason: string): void { - this.apiKeyRevocations++; - this.domainEvents.inc({ - event_type: 'api_key_revoked', - source: 'security', - ...this.getRequestContext(), - }); - } - - /** - * Record an API key anomaly detection event. - */ - recordApiKeyAnomaly(apiKeyId: string): void { - this.apiKeyAnomalies++; - this.domainEvents.inc({ - event_type: 'api_key_anomaly', - source: 'security', - ...this.getRequestContext(), - }); - } - - // ────────────────────────────────────────────────────────────────── - // Service-level error tracking - // ────────────────────────────────────────────────────────────────── - - /** - * Record a service-level error (database, redis, ai, grading, payment, - * notification). The `errorType` label should be one of the - * {@link MetricErrorTypes} constants to keep cardinality bounded. - */ - recordServiceError(service: MetricServiceName, errorType: MetricErrorType): void { - this.serviceErrors.inc({ service, error_type: errorType }); - } - - // ────────────────────────────────────────────────────────────────── - // Latency histograms per subsystem - // ────────────────────────────────────────────────────────────────── - - /** - * Record database query latency. - */ - recordDatabaseLatency(operation: string, table: string, durationMs: number): void { - this.databaseLatency.observe({ operation, table }, durationMs / 1000); - } - - /** - * Record Redis operation latency. - */ - recordRedisLatency(operation: string, durationMs: number): void { - this.redisLatency.observe({ operation }, durationMs / 1000); - } - - /** - * Record AI provider request latency. - */ - recordAiLatency(provider: string, operation: string, durationMs: number): void { - this.aiLatency.observe({ provider, operation }, durationMs / 1000); - } - - /** - * Record grading operation latency. - */ - recordGradingLatency(operation: string, durationMs: number): void { - this.gradingLatency.observe({ operation }, durationMs / 1000); - } - - /** - * Record payment operation latency. - */ - recordPaymentLatency(operation: string, provider: string, durationMs: number): void { - this.paymentLatency.observe({ operation, provider }, durationMs / 1000); - } - - /** - * Record notification delivery latency. - */ - recordNotificationLatency(channel: string, operation: string, durationMs: number): void { - this.notificationLatency.observe({ channel, operation }, durationMs / 1000); - } - - // ────────────────────────────────────────────────────────────────── - // Queue depth saturation gauges - // ────────────────────────────────────────────────────────────────── - - /** - * Set the current jobs queue depth (pending + retrying). - */ - setJobsQueueDepth(queue: string, depth: number): void { - this.jobsQueueDepth.set({ queue }, depth); - } - - /** - * Set the current dead-letter queue depth (exhausted retries). - */ - setDeadLetterQueueDepth(queue: string, depth: number): void { - this.deadLetterQueueDepth.set({ queue }, depth); - } - - // ────────────────────────────────────────────────────────────────── - // Domain outcome tracking - // ────────────────────────────────────────────────────────────────── - - /** - * Record a domain outcome event (course completion, payment success, - * notification delivered, etc.). - */ - recordDomainOutcome(outcome: string, service: string, status: 'success' | 'failure' | 'skipped'): void { - this.domainOutcomes.inc({ outcome, service, status }); - } - - /** - * Get snapshot of internal counters for health / debug endpoints. - */ - getStats(): Record { - return { - sessionRevocations: this.sessionRevocations, - apiKeyCreations: this.apiKeyCreations, - apiKeyRevocations: this.apiKeyRevocations, - apiKeyAnomalies: this.apiKeyAnomalies, - }; - } -} - -function normalizeRoute(route: string): string { - if (!route) { - return '/'; - } - return route.startsWith('/') ? route : `/${route}`; -} diff --git a/BackendAcademy/src/notifications/dto/create-notification.dto.ts b/BackendAcademy/src/notifications/dto/create-notification.dto.ts deleted file mode 100644 index d8588c3878..0000000000 --- a/BackendAcademy/src/notifications/dto/create-notification.dto.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { IsString, IsIn, IsOptional } from 'class-validator'; - -export class CreateNotificationDto { - @IsString() - userId: string; - - @IsString() - type: string; - - @IsString() - title: string; - - @IsString() - message: string; - - /** - * Optional deterministic deduplication key. - * - * When provided, the notification service will reject duplicate - * notifications that carry the same event key within the configured - * deduplication window. This prevents retries and scheduled jobs from - * sending duplicate reminders or alerts. - */ - @IsOptional() - @IsString() - eventKey?: string; -} diff --git a/BackendAcademy/src/notifications/dto/update-preferences.dto.ts b/BackendAcademy/src/notifications/dto/update-preferences.dto.ts deleted file mode 100644 index 59dff33dfb..0000000000 --- a/BackendAcademy/src/notifications/dto/update-preferences.dto.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { IsOptional, IsBoolean } from 'class-validator'; - -export class UpdateNotificationPreferencesDto { - @IsOptional() - @IsBoolean() - email_alerts?: boolean; - - @IsOptional() - @IsBoolean() - push_notifications?: boolean; - - @IsOptional() - @IsBoolean() - marketing_updates?: boolean; -} diff --git a/BackendAcademy/src/notifications/email.service.ts b/BackendAcademy/src/notifications/email.service.ts deleted file mode 100644 index 4f3497b7a4..0000000000 --- a/BackendAcademy/src/notifications/email.service.ts +++ /dev/null @@ -1,283 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; - -/** - * Default fallback values for missing personalization fields. - * - * Ensures email templates never render broken or blank content when - * user profile data is incomplete (issue #387). - */ -const FALLBACKS: Record = { - name: 'RustAcademy Learner', - email: '', - courseName: 'your course', - milestoneName: 'a new milestone', - badgeName: 'a new badge', - rewardAmount: 'a reward', - submissionTitle: 'your submission', - challengeName: 'a challenge', - tutorName: 'your tutor', -}; - -/** - * Fields available for email template personalization. - */ -export interface EmailTemplateFields { - name?: string; - email?: string; - courseName?: string; - milestoneName?: string; - badgeName?: string; - rewardAmount?: string; - submissionTitle?: string; - challengeName?: string; - tutorName?: string; - [key: string]: string | undefined; -} - -/** - * HTML entity map for escaping user-supplied values. - * Prevents XSS when interpolated into HTML templates. - */ -const HTML_ESCAPE_MAP: Record = { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''', - '/': '/', -}; - -/** - * Escapes HTML special characters in a string to prevent XSS. - * Used when interpolating user-supplied values into HTML email templates. - * - * @param raw - The raw string value to escape - * @returns The escaped string safe for HTML insertion - */ -export function escapeHtml(raw: string): string { - return raw.replace(/[&<>"'/]/g, (ch) => HTML_ESCAPE_MAP[ch] || ch); -} - -/** - * Escapes characters that are significant in plain-text email contexts. - * Specifically handles leading dots (which can be confused with SMTP - * command prefixes) and backslash-n sequences. - * - * @param raw - The raw string value to escape - * @returns The escaped string safe for plain-text insertion - */ -export function escapePlainText(raw: string): string { - // Escape leading dots (SMTP dot-stuffing safety) - return raw - .split('\n') - .map((line) => (line.startsWith('.') ? `.${line}` : line)) - .join('\n'); -} - -/** - * Strips known-dangerous HTML constructs from a value before interpolation. - * This is a defence-in-depth measure; primary protection is escaping. - * - * Removes `')).toBe( - '<script>alert("xss")</script>', - ); - }); - }); - - describe('stripDangerousHtml', () => { - it('removes script tags', () => { - expect(stripDangerousHtml('Hello World')).toBe('Hello World'); - }); - - it('removes script tags with attributes', () => { - expect(stripDangerousHtml('A B')).toBe('A B'); - }); - - it('removes iframe tags', () => { - expect(stripDangerousHtml('X Y')).toBe('X Y'); - }); - - it('removes object tags', () => { - expect(stripDangerousHtml('X Y')).toBe('X Y'); - }); - - it('removes embed tags', () => { - expect(stripDangerousHtml('X Y')).toBe('X Y'); - }); - - it('strips javascript: URIs', () => { - expect(stripDangerousHtml('click javascript:alert(1)')).toBe('click alert(1)'); - }); - - it('handles case-insensitive script tags', () => { - expect(stripDangerousHtml('')).toBe(''); - }); - - it('preserves safe HTML', () => { - const input = 'Hello World'; - expect(stripDangerousHtml(input)).toBe(input); - }); - }); - - describe('sanitiseTemplateValue', () => { - it('strips and escapes dangerous content', () => { - const result = sanitiseTemplateValue('Hello & goodbye'); - expect(result).not.toContain('