diff --git a/.dockerignore b/.dockerignore index 80455a8e..0174ae4c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,3 +7,9 @@ dist .env .DS_Store *.log +backend +frontend/node_modules +frontend/dist +frontend/coverage +frontend/test-results +frontend/playwright-report diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c7b9ddd9..7bb18e09 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -108,7 +108,7 @@ jobs: run: | # Start backend server in background cd backend - DATABASE_URL="file:${{ github.workspace }}/backend/prisma/e2e-test.db" FRONTEND_URL="http://localhost:5173" npm run dev & + DATABASE_URL="file:${{ github.workspace }}/backend/prisma/e2e-test.db" FRONTEND_URL="http://localhost:6767" npm run dev & BACKEND_PID=$! cd .. @@ -132,7 +132,7 @@ jobs: # Wait for frontend to be ready echo "Waiting for frontend server..." for i in {1..30}; do - if curl -s http://localhost:5173 > /dev/null; then + if curl -s http://localhost:6767 > /dev/null; then echo "Frontend is ready!" break fi diff --git a/.pnpm-store/v10/projects/3d9dc8786854ec3088fcfd3c145118e4 b/.pnpm-store/v10/projects/3d9dc8786854ec3088fcfd3c145118e4 new file mode 120000 index 00000000..49579a45 --- /dev/null +++ b/.pnpm-store/v10/projects/3d9dc8786854ec3088fcfd3c145118e4 @@ -0,0 +1 @@ +../../../frontend \ No newline at end of file diff --git a/FORK.md b/FORK.md new file mode 100644 index 00000000..6c25df66 --- /dev/null +++ b/FORK.md @@ -0,0 +1,69 @@ +# Fork Summary + +This fork adds optional security features and UX improvements with **zero breaking changes** and **minimal migration overhead**. All security features are **disabled by default** via feature flags. + +## Security Features Added + +1. **Password Reset** - Token-based password reset flow (`/auth/password-reset-request`, `/auth/password-reset-confirm`) +2. **Refresh Token Rotation** - Prevents token reuse by rotating refresh tokens on each use +3. **Audit Logging** - Logs security events (logins, password changes, deletions) for compliance + +## UX Improvements Added + +1. **Profile Page** - View and edit personal information, change password (`/profile`) +2. **Select All Button** - Quick selection of all drawings in current view +3. **Sort Dropdown** - Improved sort controls with icons and separate direction toggle +4. **Auto-hide Header** - Editor header auto-hides to maximize drawing space (with toggle) + +## Backward Compatibility + +✅ All security features disabled by default +✅ No breaking changes to existing code +✅ Graceful degradation (missing tables don't cause errors) +✅ Optional database migration + +## Enable Security Features + +Set in `backend/.env`: +```bash +ENABLE_PASSWORD_RESET=true +ENABLE_REFRESH_TOKEN_ROTATION=true +ENABLE_AUDIT_LOGGING=true +``` + +Then run migration: +```bash +cd backend && npx prisma migrate deploy +``` + +## Migration Strategy + +**For base project:** Keep features disabled (default) - no migration needed, zero risk. + +**For this fork:** Enable features via environment variables when ready. + +## Database Changes + +Migration adds 3 optional tables (only used when features enabled): +- `PasswordResetToken` - For password reset flow +- `RefreshToken` - For token rotation tracking +- `AuditLog` - For security event logging + +## Code Changes + +### Backend +- Feature flags in `backend/src/config.ts` +- Conditional logic in auth endpoints +- Graceful error handling for missing tables +- New endpoints: `/auth/profile` (PUT), `/auth/change-password` (POST) +- Audit logging utility (`backend/src/utils/audit.ts`) + +### Frontend +- Password reset pages (`/reset-password`, `/reset-password-confirm`) +- Profile page (`/profile`) +- Select All button in Dashboard +- Sort dropdown with icons +- Auto-hide header in Editor with toggle +- Updated API client for token rotation + +All changes are backward compatible and optional. diff --git a/README.md b/README.md index 6b566c00..b8af4385 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ ![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg) [![Docker](https://img.shields.io/badge/docker-ready-blue.svg)](https://hub.docker.com) +_Original repo can be found [here](https://github.com/ZimengXiong/ExcaliDash)_ + A self-hosted dashboard and organizer for [Excalidraw](https://github.com/excalidraw/excalidraw) with live collaboration features. ## Screenshots @@ -99,6 +101,10 @@ docker compose -f docker-compose.prod.yml up -d # Access the frontend at localhost:6767 ``` +For single-container deployments, `JWT_SECRET` can be omitted and will be auto-generated and persisted in the backend volume on first start. For portability and all multi-instance deployments, set a fixed `JWT_SECRET` explicitly. + +By default, the provided Compose files set `TRUST_PROXY=false` for safer setup. Only set `TRUST_PROXY` to a positive hop count (for example, `1`) when requests always pass through a trusted reverse proxy that correctly sets forwarded headers. + ## Docker Build [Install Docker](https://docs.docker.com/desktop/) @@ -121,6 +127,7 @@ docker compose up -d When running ExcaliDash behind Traefik, Nginx, or another reverse proxy, configure both containers so that API + WebSocket calls resolve correctly: - `FRONTEND_URL` (backend) must match the public URL that users hit (e.g. `https://excalidash.example.com`). This controls CORS and Socket.IO origin checks. **Supports multiple comma-separated URLs** for accessing from different addresses. +- `TRUST_PROXY` (backend) should be set to `1` when requests pass through one trusted reverse proxy hop (for example: frontend nginx -> backend) and forwarded headers are sanitized. This ensures rate limiting and logging use the real client IP from trusted proxy headers. - `BACKEND_URL` (frontend) tells the Nginx container how to reach the backend from inside Docker/Kubernetes. Override it if your reverse proxy exposes the backend under a different hostname. ```yaml @@ -129,6 +136,8 @@ backend: environment: # Single URL - FRONTEND_URL=https://excalidash.example.com + # Trust exactly one reverse-proxy hop + - TRUST_PROXY=1 # Or multiple URLs (comma-separated) for local + network access # - FRONTEND_URL=http://localhost:6767,http://192.168.1.100:6767,http://nas.local:6767 frontend: @@ -141,7 +150,7 @@ frontend: ### Multi-Container / Kubernetes Deployments -When running multiple backend replicas (e.g., Kubernetes, Docker Swarm, or load-balanced containers), you **must** set the `CSRF_SECRET` environment variable to the same value across all instances. +When running multiple backend replicas (e.g., Kubernetes, Docker Swarm, or load-balanced containers), you **must** set both `JWT_SECRET` and `CSRF_SECRET` to the same values across all instances. ```bash # Generate a secure secret @@ -152,11 +161,37 @@ openssl rand -base64 32 # docker-compose.yml or k8s deployment backend: environment: + - JWT_SECRET=your-generated-jwt-secret-here - CSRF_SECRET=your-generated-secret-here ``` Without this, each container generates its own ephemeral CSRF secret, causing token validation failures when requests are routed to different replicas. Single-container deployments work without this setting. +### Authentication Modes (Local + OIDC) + +ExcaliDash supports three auth modes via backend `AUTH_MODE`: + +- `local` (default): native email/password login only. +- `hybrid`: native login + OIDC login. +- `oidc_enforced`: OIDC-only login (native login/register disabled). + +For OIDC modes (`hybrid` or `oidc_enforced`), set: + +```yaml +backend: + environment: + - AUTH_MODE=oidc_enforced + - OIDC_PROVIDER_NAME=Authentik + - OIDC_ISSUER_URL=https://auth.example.com/application/o/excalidash/ + - OIDC_CLIENT_ID=your-client-id + - OIDC_CLIENT_SECRET=your-client-secret + - OIDC_REDIRECT_URI=https://excalidash.example.com/api/auth/oidc/callback + - OIDC_SCOPES=openid profile email +``` + +In `oidc_enforced` mode, unauthenticated users are automatically redirected to `/api/auth/oidc/start`. +Users are linked by `(issuer, sub)` first, then by verified email, and optionally auto-provisioned. + # Development ## Clone the Repository @@ -197,6 +232,27 @@ npx prisma db push npm run dev ``` +### Simulate Auth Onboarding (Development) + +To simulate first-run authentication choice flows in local development: + +```bash +cd ExcaliDash/backend + +# Preview what would change (no data modifications) +npm run dev:simulate-auth-onboarding:dry-run + +# Simulate "fresh install" onboarding state +# (wipes drawings/collections/libraries and removes non-bootstrap users) +npm run dev:simulate-auth-onboarding:fresh + +# Simulate "migration" onboarding state (ensures legacy data exists) +npm run dev:simulate-auth-onboarding:migration +``` + +After running a simulation while the backend is already running, wait about 5 seconds +(auth mode cache TTL) or restart the backend before refreshing the UI. + ## Project Structure ``` diff --git a/RELEASE.md b/RELEASE.md index ebbe11da..a767019f 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,43 +1,9 @@ -CSRF Protection (8a78b2b) +Multi user setup is opt-in, single user by default - - Implemented comprehensive CSRF (Cross-Site Request Forgery) protection for enhanced security - - Added new backend/src/security.ts module for security utilities - - Frontend API layer now handles CSRF tokens automatically - - Added integration tests for CSRF validation +Multi-user support for excalidash +- Admin dashboard +- Password reset, force user password reset (admin only), account lockout recovery +- Rate limits - Upload Progress Indicator (8f9b9b4) +Deprecates .json and .sqlite database backups in favor of .excalidash archives (user scoped, prevents exporting of senstive information). Legacy import is maintained. - - Added a visual upload progress bar when users upload files - - New UploadContext for managing upload state across components - - New UploadStatus component displaying real-time upload progress - - Save status indicator when navigating back from the editor - - Improved error handling and recovery for failed uploads - - Bug Fixes - - - Fixed broken e2e tests (cae8f3c) - - Replaced deprecated substr() with substring() - - Fixed stale state issues in error handling - - Fixed missing useEffect dependencies - - Fixed CSS class conflicts in progress bar styling - - Added error recovery for save state in Editor - - Infrastructure - - - Updated docker-compose configurations with new environment variables - - E2E test suite improvements and reliability fixes - - Added Kubernetes deployment note in README - -### Kubernetes - - A `CSRF_SECRET` environment variable is now required for CSRF protection. Generate a secure 32+ character random string: - - ```bash - openssl rand -base64 32 - - Add it to your deployment: - - Docker Compose: Add CSRF_SECRET= to the backend service environment - - Kubernetes: Add to your ConfigMap/Secret and reference in the backend deployment - - If not set, the backend will refuse to start. - ``` diff --git a/VERSION b/VERSION index d15723fb..ef52a648 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.2 +0.4.6 diff --git a/backend/.dockerignore b/backend/.dockerignore index 567bf5c8..e526d443 100644 --- a/backend/.dockerignore +++ b/backend/.dockerignore @@ -9,3 +9,7 @@ dist *.log prisma/dev.db prisma/dev.db-journal +src/generated +coverage +*.test.ts +*.spec.ts diff --git a/backend/.env.example b/backend/.env.example index da110e96..867c39ab 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -2,4 +2,28 @@ PORT=8000 NODE_ENV=production DATABASE_URL=file:/app/prisma/dev.db -FRONTEND_URL=http://localhost:6767 \ No newline at end of file +FRONTEND_URL=https://draw.louiscreates.com +API_BASE_PATH=/api +# Keep disabled unless traffic always comes through a trusted reverse proxy. +TRUST_PROXY=false +AUTH_MODE=local +JWT_SECRET=change-this-secret-in-production-min-32-chars + +# Optional Feature Flags (all default to false for backward compatibility) +# Set to "true" or "1" to enable: +# ENABLE_PASSWORD_RESET=false +# ENABLE_REFRESH_TOKEN_ROTATION=false +# ENABLE_AUDIT_LOGGING=false + +# OIDC Configuration (required when AUTH_MODE=hybrid or AUTH_MODE=oidc_enforced) +# OIDC_PROVIDER_NAME=Authentik +# OIDC_ISSUER_URL=https://auth.example.com/application/o/excalidash/ +# OIDC_CLIENT_ID=your-client-id +# OIDC_CLIENT_SECRET=your-client-secret +# OIDC_REDIRECT_URI=https://excalidash.example.com/api/auth/oidc/callback +# OIDC_SCOPES=openid profile email +# OIDC_EMAIL_CLAIM=email +# OIDC_EMAIL_VERIFIED_CLAIM=email_verified +# OIDC_REQUIRE_EMAIL_VERIFIED=true +# OIDC_JIT_PROVISIONING=true +# OIDC_FIRST_USER_ADMIN=true diff --git a/backend/Dockerfile b/backend/Dockerfile index 53589ec1..e50b0eec 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -3,12 +3,15 @@ FROM node:20-alpine AS builder WORKDIR /app +# Native build deps for modules that may compile from source (e.g., better-sqlite3 on arm64) +RUN apk add --no-cache python3 make g++ + # Copy package files COPY package*.json ./ COPY tsconfig.json ./ # Install dependencies -RUN npm ci +RUN npm ci && npm cache clean --force # Copy prisma schema COPY prisma ./prisma/ @@ -25,7 +28,7 @@ RUN npx tsc # Production stage FROM node:20-alpine -# Install OpenSSL for Prisma and su-exec, create non-root user +# Install runtime packages and create non-root user RUN apk add --no-cache openssl su-exec && \ addgroup -g 1001 -S nodejs && \ adduser -S nodejs -u 1001 @@ -36,7 +39,10 @@ WORKDIR /app COPY package*.json ./ # Install production dependencies only -RUN npm ci --only=production +RUN apk add --no-cache --virtual .build-deps python3 make g++ && \ + npm ci --omit=dev && \ + npm cache clean --force && \ + apk del .build-deps # Copy prisma schema and migrations for runtime and hydration template COPY prisma ./prisma/ @@ -48,9 +54,6 @@ COPY --from=builder /app/dist ./dist # Copy the generated Prisma Client from builder to maintain the same structure COPY --from=builder /app/src/generated ./dist/generated -# Generate Prisma Client in production (updates node_modules) -RUN npx prisma generate - # Create necessary directories (ownership will be set in entrypoint) RUN mkdir -p /app/uploads /app/prisma diff --git a/backend/docker-entrypoint.sh b/backend/docker-entrypoint.sh index 07889f6d..56cd0d30 100644 --- a/backend/docker-entrypoint.sh +++ b/backend/docker-entrypoint.sh @@ -1,6 +1,52 @@ #!/bin/sh set -e +JWT_SECRET_FILE="/app/prisma/.jwt_secret" +CSRF_SECRET_FILE="/app/prisma/.csrf_secret" + +# Ensure JWT secret exists for production startup. +# Backward compatibility: older installs may not have JWT_SECRET configured. +if [ -z "${JWT_SECRET:-}" ]; then + echo "JWT_SECRET not provided, resolving persisted secret..." + if [ -f "${JWT_SECRET_FILE}" ]; then + JWT_SECRET="$(tr -d '\r\n' < "${JWT_SECRET_FILE}")" + fi + + if [ -z "${JWT_SECRET}" ]; then + echo "No persisted JWT secret found. Generating a new secret..." + JWT_SECRET="$(openssl rand -hex 32)" + umask 077 + printf "%s" "${JWT_SECRET}" > "${JWT_SECRET_FILE}" + fi +else + # Persist explicitly provided secret to support future restarts without env injection. + umask 077 + printf "%s" "${JWT_SECRET}" > "${JWT_SECRET_FILE}" +fi + +export JWT_SECRET + +# Ensure CSRF secret exists for stable token validation across restarts. +# (Still recommend setting explicitly for multi-instance deployments.) +if [ -z "${CSRF_SECRET:-}" ]; then + echo "CSRF_SECRET not provided, resolving persisted secret..." + if [ -f "${CSRF_SECRET_FILE}" ]; then + CSRF_SECRET="$(tr -d '\r\n' < "${CSRF_SECRET_FILE}")" + fi + + if [ -z "${CSRF_SECRET}" ]; then + echo "No persisted CSRF secret found. Generating a new secret..." + CSRF_SECRET="$(openssl rand -base64 32)" + umask 077 + printf "%s" "${CSRF_SECRET}" > "${CSRF_SECRET_FILE}" + fi +else + umask 077 + printf "%s" "${CSRF_SECRET}" > "${CSRF_SECRET_FILE}" +fi + +export CSRF_SECRET + # 1. Hydrate volume if empty (Running as root) if [ ! -f "/app/prisma/schema.prisma" ]; then echo "Mount is empty. Hydrating /app/prisma..." @@ -18,11 +64,13 @@ echo "Fixing filesystem permissions..." chown -R nodejs:nodejs /app/uploads chown -R nodejs:nodejs /app/prisma chmod 755 /app/uploads +chmod 600 "${JWT_SECRET_FILE}" +chmod 600 "${CSRF_SECRET_FILE}" # Ensure database file has proper permissions if [ -f "/app/prisma/dev.db" ]; then echo "Database file found, ensuring write permissions..." - chmod 666 /app/prisma/dev.db + chmod 600 /app/prisma/dev.db fi # 3. Run Migrations (Drop privileges to nodejs) diff --git a/backend/package-lock.json b/backend/package-lock.json index 0f3ec731..c7dd9772 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,36 +1,48 @@ { "name": "backend", - "version": "0.3.1", + "version": "0.4.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "backend", - "version": "0.3.1", + "version": "0.4.6", "license": "ISC", "dependencies": { "@prisma/client": "^5.22.0", - "@types/archiver": "^7.0.0", - "@types/jsdom": "^21.1.7", - "@types/multer": "^2.0.0", - "@types/socket.io": "^3.0.1", "archiver": "^7.0.1", + "bcrypt": "^6.0.0", "better-sqlite3": "^12.4.6", "cors": "^2.8.5", "dompurify": "^3.3.0", "dotenv": "^17.2.3", "express": "^5.1.0", + "express-rate-limit": "^8.2.1", + "helmet": "^8.1.0", "jsdom": "^22.1.0", + "jsonwebtoken": "^9.0.3", + "jszip": "^3.10.1", + "ms": "^2.1.3", "multer": "^2.0.2", + "openid-client": "^5.7.1", "prisma": "^5.22.0", "socket.io": "^4.8.1", + "uuid": "^13.0.0", "zod": "^4.1.12" }, "devDependencies": { + "@types/archiver": "^7.0.0", + "@types/bcrypt": "^6.0.0", "@types/cors": "^2.8.19", "@types/express": "^5.0.5", + "@types/jsdom": "^21.1.7", + "@types/jsonwebtoken": "^9.0.10", + "@types/ms": "^2.1.0", + "@types/multer": "^2.0.0", "@types/node": "^24.10.1", + "@types/socket.io": "^3.0.1", "@types/supertest": "^6.0.3", + "@types/uuid": "^10.0.0", "nodemon": "^3.1.11", "supertest": "^7.1.4", "ts-node": "^10.9.2", @@ -996,15 +1008,27 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-7.0.0.tgz", "integrity": "sha512-/3vwGwx9n+mCQdYZ2IKGGHEFL30I96UgBlk8EtRDDFQ9uxM1l4O5Ci6r00EMAkiDaTqD9DQ6nVrWRICnBPtzzg==", + "dev": true, "license": "MIT", "dependencies": { "@types/readdir-glob": "*" } }, + "node_modules/@types/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "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": "*", @@ -1026,6 +1050,7 @@ "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": "*" @@ -1065,6 +1090,7 @@ "version": "5.0.5", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.5.tgz", "integrity": "sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==", + "dev": true, "license": "MIT", "dependencies": { "@types/body-parser": "*", @@ -1076,6 +1102,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz", "integrity": "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -1088,12 +1115,14 @@ "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/jsdom": { "version": "21.1.7", "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz", "integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -1101,6 +1130,17 @@ "parse5": "^7.0.0" } }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, "node_modules/@types/methods": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", @@ -1112,12 +1152,21 @@ "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, "license": "MIT" }, "node_modules/@types/multer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz", "integrity": "sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==", + "dev": true, "license": "MIT", "dependencies": { "@types/express": "*" @@ -1136,18 +1185,21 @@ "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "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/readdir-glob": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@types/readdir-glob/-/readdir-glob-1.1.5.tgz", "integrity": "sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -1157,6 +1209,7 @@ "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": "*" @@ -1166,6 +1219,7 @@ "version": "1.15.10", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, "license": "MIT", "dependencies": { "@types/http-errors": "*", @@ -1177,6 +1231,7 @@ "version": "0.17.6", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, "license": "MIT", "dependencies": { "@types/mime": "^1", @@ -1187,6 +1242,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/@types/socket.io/-/socket.io-3.0.1.tgz", "integrity": "sha512-XSma2FhVD78ymvoxYV4xGXrIH/0EKQ93rR+YR0Y+Kw1xbPzLDCip/UWSejZ08FpxYeYNci/PZPQS9anrvJRqMA==", + "dev": true, "license": "MIT", "dependencies": { "socket.io": "*" @@ -1220,6 +1276,7 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true, "license": "MIT" }, "node_modules/@types/trusted-types": { @@ -1229,6 +1286,13 @@ "license": "MIT", "optional": true }, + "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/@vitest/expect": { "version": "4.0.15", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.15.tgz", @@ -1621,6 +1685,20 @@ "node": "^4.5.0 || >= 5.9" } }, + "node_modules/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/better-sqlite3": { "version": "12.4.6", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.4.6.tgz", @@ -1789,6 +1867,12 @@ "node": ">=8.0.0" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -2219,9 +2303,9 @@ } }, "node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "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": { @@ -2282,6 +2366,15 @@ "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", @@ -2621,6 +2714,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", + "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "license": "MIT", + "dependencies": { + "ip-address": "10.0.1" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/fast-fifo": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", @@ -2955,6 +3066,15 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", + "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/html-encoding-sniffer": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", @@ -3053,6 +3173,12 @@ "dev": true, "license": "ISC" }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -3065,6 +3191,15 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -3180,6 +3315,15 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/jsdom": { "version": "22.1.0", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-22.1.0.tgz", @@ -3243,6 +3387,91 @@ } } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "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/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/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/jszip/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/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "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": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/lazystream": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", @@ -3285,10 +3514,61 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "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.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/lru-cache": { @@ -3566,6 +3846,26 @@ "node": ">=10" } }, + "node_modules/node-addon-api": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz", + "integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/nodemon": { "version": "3.1.11", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz", @@ -3619,6 +3919,15 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -3642,6 +3951,15 @@ ], "license": "MIT" }, + "node_modules/oidc-token-hash": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz", + "integrity": "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==", + "license": "MIT", + "engines": { + "node": "^10.13.0 || >=12.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -3663,12 +3981,45 @@ "wrappy": "1" } }, + "node_modules/openid-client": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", + "integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==", + "license": "MIT", + "dependencies": { + "jose": "^4.15.9", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/openid-client/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "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/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", @@ -3893,9 +4244,9 @@ } }, "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -4182,6 +4533,12 @@ "node": ">= 18" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -5032,6 +5389,19 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/uuid": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/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", @@ -5474,6 +5844,12 @@ "node": ">=0.4" } }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", diff --git a/backend/package.json b/backend/package.json index bb277aec..527df4a2 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,10 +1,15 @@ { "name": "backend", - "version": "0.3.2", + "version": "0.4.6", "description": "", "main": "index.js", "scripts": { + "predev": "node scripts/predev-migrate.cjs", "dev": "nodemon src/index.ts", + "admin:recover": "node scripts/admin-recover.cjs", + "dev:simulate-auth-onboarding:fresh": "node scripts/simulate-auth-onboarding.cjs --scenario fresh", + "dev:simulate-auth-onboarding:migration": "node scripts/simulate-auth-onboarding.cjs --scenario migration", + "dev:simulate-auth-onboarding:dry-run": "node scripts/simulate-auth-onboarding.cjs --scenario migration --dry-run", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage" @@ -15,27 +20,39 @@ "type": "commonjs", "dependencies": { "@prisma/client": "^5.22.0", - "@types/archiver": "^7.0.0", - "@types/jsdom": "^21.1.7", - "@types/multer": "^2.0.0", - "@types/socket.io": "^3.0.1", "archiver": "^7.0.1", + "bcrypt": "^6.0.0", "better-sqlite3": "^12.4.6", "cors": "^2.8.5", "dompurify": "^3.3.0", "dotenv": "^17.2.3", "express": "^5.1.0", + "express-rate-limit": "^8.2.1", + "helmet": "^8.1.0", "jsdom": "^22.1.0", + "jsonwebtoken": "^9.0.3", + "jszip": "^3.10.1", + "ms": "^2.1.3", "multer": "^2.0.2", + "openid-client": "^5.7.1", "prisma": "^5.22.0", "socket.io": "^4.8.1", + "uuid": "^13.0.0", "zod": "^4.1.12" }, "devDependencies": { + "@types/archiver": "^7.0.0", + "@types/bcrypt": "^6.0.0", "@types/cors": "^2.8.19", "@types/express": "^5.0.5", + "@types/jsdom": "^21.1.7", + "@types/jsonwebtoken": "^9.0.10", + "@types/ms": "^2.1.0", + "@types/multer": "^2.0.0", "@types/node": "^24.10.1", + "@types/socket.io": "^3.0.1", "@types/supertest": "^6.0.3", + "@types/uuid": "^10.0.0", "nodemon": "^3.1.11", "supertest": "^7.1.4", "ts-node": "^10.9.2", diff --git a/backend/pnpm-lock.yaml b/backend/pnpm-lock.yaml new file mode 100644 index 00000000..02e67174 --- /dev/null +++ b/backend/pnpm-lock.yaml @@ -0,0 +1,3783 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@prisma/client': + specifier: ^5.22.0 + version: 5.22.0(prisma@5.22.0) + archiver: + specifier: ^7.0.1 + version: 7.0.1 + bcrypt: + specifier: ^6.0.0 + version: 6.0.0 + better-sqlite3: + specifier: ^12.4.6 + version: 12.6.2 + cors: + specifier: ^2.8.5 + version: 2.8.6 + dompurify: + specifier: ^3.3.0 + version: 3.3.1 + dotenv: + specifier: ^17.2.3 + version: 17.2.4 + express: + specifier: ^5.1.0 + version: 5.2.1 + express-rate-limit: + specifier: ^8.2.1 + version: 8.2.1(express@5.2.1) + helmet: + specifier: ^8.1.0 + version: 8.1.0 + jsdom: + specifier: ^22.1.0 + version: 22.1.0 + jsonwebtoken: + specifier: ^9.0.3 + version: 9.0.3 + jszip: + specifier: ^3.10.1 + version: 3.10.1 + ms: + specifier: ^2.1.3 + version: 2.1.3 + multer: + specifier: ^2.0.2 + version: 2.0.2 + openid-client: + specifier: ^5.7.1 + version: 5.7.1 + prisma: + specifier: ^5.22.0 + version: 5.22.0 + socket.io: + specifier: ^4.8.1 + version: 4.8.3 + uuid: + specifier: ^13.0.0 + version: 13.0.0 + zod: + specifier: ^4.1.12 + version: 4.3.6 + devDependencies: + '@types/archiver': + specifier: ^7.0.0 + version: 7.0.0 + '@types/bcrypt': + specifier: ^6.0.0 + version: 6.0.0 + '@types/cors': + specifier: ^2.8.19 + version: 2.8.19 + '@types/express': + specifier: ^5.0.5 + version: 5.0.6 + '@types/jsdom': + specifier: ^21.1.7 + version: 21.1.7 + '@types/jsonwebtoken': + specifier: ^9.0.10 + version: 9.0.10 + '@types/ms': + specifier: ^2.1.0 + version: 2.1.0 + '@types/multer': + specifier: ^2.0.0 + version: 2.0.0 + '@types/node': + specifier: ^24.10.1 + version: 24.10.13 + '@types/socket.io': + specifier: ^3.0.1 + version: 3.0.2 + '@types/supertest': + specifier: ^6.0.3 + version: 6.0.3 + '@types/uuid': + specifier: ^10.0.0 + version: 10.0.0 + nodemon: + specifier: ^3.1.11 + version: 3.1.11 + supertest: + specifier: ^7.1.4 + version: 7.2.2 + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@24.10.13)(typescript@5.9.3) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.0.15 + version: 4.0.18(@types/node@24.10.13)(jsdom@22.1.0) + +packages: + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@esbuild/aix-ppc64@0.27.3': + resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.3': + resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.3': + resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.3': + resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.3': + resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.3': + resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.3': + resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.3': + resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.3': + resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.3': + resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.3': + resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.3': + resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.3': + resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.3': + resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.3': + resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.3': + resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.3': + resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.3': + resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.3': + resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.3': + resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.3': + resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.3': + resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.3': + resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.3': + resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.3': + resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.3': + resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@paralleldrive/cuid2@2.3.1': + resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@prisma/client@5.22.0': + resolution: {integrity: sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==} + engines: {node: '>=16.13'} + peerDependencies: + prisma: '*' + peerDependenciesMeta: + prisma: + optional: true + + '@prisma/debug@5.22.0': + resolution: {integrity: sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==} + + '@prisma/engines-version@5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2': + resolution: {integrity: sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==} + + '@prisma/engines@5.22.0': + resolution: {integrity: sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==} + + '@prisma/fetch-engine@5.22.0': + resolution: {integrity: sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==} + + '@prisma/get-platform@5.22.0': + resolution: {integrity: sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==} + + '@rollup/rollup-android-arm-eabi@4.57.1': + resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.57.1': + resolution: {integrity: sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.57.1': + resolution: {integrity: sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.57.1': + resolution: {integrity: sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.57.1': + resolution: {integrity: sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.57.1': + resolution: {integrity: sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.57.1': + resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.57.1': + resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.57.1': + resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.57.1': + resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.57.1': + resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.57.1': + resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.57.1': + resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.57.1': + resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.57.1': + resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.57.1': + resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.57.1': + resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.57.1': + resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.57.1': + resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.57.1': + resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.57.1': + resolution: {integrity: sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.57.1': + resolution: {integrity: sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.57.1': + resolution: {integrity: sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.57.1': + resolution: {integrity: sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.57.1': + resolution: {integrity: sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==} + cpu: [x64] + os: [win32] + + '@socket.io/component-emitter@3.1.2': + resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tootallnate/once@2.0.0': + resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} + engines: {node: '>= 10'} + + '@tsconfig/node10@1.0.12': + resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==} + + '@tsconfig/node12@1.0.11': + resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} + + '@tsconfig/node14@1.0.3': + resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} + + '@tsconfig/node16@1.0.4': + resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + + '@types/archiver@7.0.0': + resolution: {integrity: sha512-/3vwGwx9n+mCQdYZ2IKGGHEFL30I96UgBlk8EtRDDFQ9uxM1l4O5Ci6r00EMAkiDaTqD9DQ6nVrWRICnBPtzzg==} + + '@types/bcrypt@6.0.0': + resolution: {integrity: sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==} + + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/cookiejar@2.1.5': + resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + + '@types/cors@2.8.19': + resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/express-serve-static-core@5.1.1': + resolution: {integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==} + + '@types/express@5.0.6': + resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + + '@types/jsdom@21.1.7': + resolution: {integrity: sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==} + + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + + '@types/methods@1.1.4': + resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/multer@2.0.0': + resolution: {integrity: sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==} + + '@types/node@24.10.13': + resolution: {integrity: sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==} + + '@types/qs@6.14.0': + resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + '@types/readdir-glob@1.1.5': + resolution: {integrity: sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==} + + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@2.2.0': + resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + + '@types/socket.io@3.0.2': + resolution: {integrity: sha512-pu0sN9m5VjCxBZVK8hW37ZcMe8rjn4HHggBN5CbaRTvFwv5jOmuIRZEuddsBPa9Th0ts0SIo3Niukq+95cMBbQ==} + deprecated: This is a stub types definition. socket.io provides its own type definitions, so you do not need this installed. + + '@types/superagent@8.1.9': + resolution: {integrity: sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==} + + '@types/supertest@6.0.3': + resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} + + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@types/uuid@10.0.0': + resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} + + '@vitest/expect@4.0.18': + resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} + + '@vitest/mocker@4.0.18': + resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.0.18': + resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} + + '@vitest/runner@4.0.18': + resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} + + '@vitest/snapshot@4.0.18': + resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} + + '@vitest/spy@4.0.18': + resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} + + '@vitest/utils@4.0.18': + resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + + abab@2.0.6: + resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} + deprecated: Use your platform's native atob() and btoa() methods instead + + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + acorn-walk@8.3.4: + resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} + engines: {node: '>=0.4.0'} + + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + append-field@1.0.0: + resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} + + archiver-utils@5.0.2: + resolution: {integrity: sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==} + engines: {node: '>= 14'} + + archiver@7.0.1: + resolution: {integrity: sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==} + engines: {node: '>= 14'} + + arg@4.1.3: + resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + b4a@1.7.3: + resolution: {integrity: sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + bare-events@2.8.2: + resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + base64id@2.0.0: + resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==} + engines: {node: ^4.5.0 || >= 5.9} + + bcrypt@6.0.0: + resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==} + engines: {node: '>= 18'} + + better-sqlite3@12.6.2: + resolution: {integrity: sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==} + engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + buffer-crc32@1.0.0: + resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} + engines: {node: '>=8.0.0'} + + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + + busboy@1.6.0: + resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} + engines: {node: '>=10.16.0'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + component-emitter@1.3.1: + resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} + + compress-commons@6.0.2: + resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==} + engines: {node: '>= 14'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + concat-stream@2.0.0: + resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} + engines: {'0': node >= 6.0} + + content-disposition@1.0.1: + resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cookiejar@2.1.4: + resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + + crc32-stream@6.0.0: + resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==} + engines: {node: '>= 14'} + + create-require@1.1.1: + resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + cssstyle@3.0.0: + resolution: {integrity: sha512-N4u2ABATi3Qplzf0hWbVCdjenim8F3ojEXpBDF5hBpjzW182MjNGLqfmQ0SkSPeQ+V86ZXgeH8aXj6kayd4jgg==} + engines: {node: '>=14'} + + data-urls@4.0.0: + resolution: {integrity: sha512-/mMTei/JXPqvFqQtfyTowxmJVwr2PVAeCcDxyFf6LhoOu/09TX2OX3kb2wzi4DMXcfj4OItwDOnhl5oziPnT6g==} + engines: {node: '>=14'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dezalgo@1.0.4: + resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} + + diff@4.0.4: + resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==} + engines: {node: '>=0.3.1'} + + domexception@4.0.0: + resolution: {integrity: sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==} + engines: {node: '>=12'} + deprecated: Use your platform's native DOMException instead + + dompurify@3.3.1: + resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==} + + dotenv@17.2.4: + resolution: {integrity: sha512-mudtfb4zRB4bVvdj0xRo+e6duH1csJRM8IukBqfTRvHotn9+LBXB8ynAidP9zHqoRC/fsllXgk4kCKlR21fIhw==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + engine.io-parser@5.2.3: + resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} + engines: {node: '>=10.0.0'} + + engine.io@6.6.5: + resolution: {integrity: sha512-2RZdgEbXmp5+dVbRm0P7HQUImZpICccJy7rN7Tv+SFa55pH+lxnuw6/K1ZxxBfHoYpSkHLAO92oa8O4SwFXA2A==} + engines: {node: '>=10.2.0'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.27.3: + resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} + engines: {node: '>=18'} + hasBin: true + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + express-rate-limit@8.2.1: + resolution: {integrity: sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + + fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + formidable@3.5.4: + resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} + engines: {node: '>=14.0.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob@10.5.0: + resolution: {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 + hasBin: true + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + helmet@8.1.0: + resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==} + engines: {node: '>=18.0.0'} + + html-encoding-sniffer@3.0.0: + resolution: {integrity: sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==} + engines: {node: '>=12'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + http-proxy-agent@5.0.0: + resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} + engines: {node: '>= 6'} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + ignore-by-default@1.0.1: + resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==} + + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + ip-address@10.0.1: + resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + jose@4.15.9: + resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==} + + jsdom@22.1.0: + resolution: {integrity: sha512-/9AVW7xNbsBv6GfWho4TTNjEo9fe6Zhf9O7s0Fhhr3u+awPwAJMKwAMXnkk5vBxflqLW9hTHX/0cs+P3gW+cQw==} + engines: {node: '>=16'} + peerDependencies: + canvas: ^2.5.0 + peerDependenciesMeta: + canvas: + optional: true + + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + + lazystream@1.0.1: + resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} + engines: {node: '>= 0.6.3'} + + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + + lodash@4.17.23: + resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimatch@5.1.6: + resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} + engines: {node: '>=10'} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + + mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + multer@2.0.2: + resolution: {integrity: sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==} + engines: {node: '>= 10.16.0'} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + node-abi@3.87.0: + resolution: {integrity: sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==} + engines: {node: '>=10'} + + node-addon-api@8.5.0: + resolution: {integrity: sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==} + engines: {node: ^18 || ^20 || >= 21} + + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + + nodemon@3.1.11: + resolution: {integrity: sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==} + engines: {node: '>=10'} + hasBin: true + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + nwsapi@2.2.23: + resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-hash@2.2.0: + resolution: {integrity: sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==} + engines: {node: '>= 6'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + oidc-token-hash@5.2.0: + resolution: {integrity: sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw==} + engines: {node: ^10.13.0 || >=12.0.0} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + openid-client@5.7.1: + resolution: {integrity: sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + path-to-regexp@8.3.0: + resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + hasBin: true + + prisma@5.22.0: + resolution: {integrity: sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==} + engines: {node: '>=16.13'} + hasBin: true + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + psl@1.15.0: + resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + + pstree.remy@1.1.8: + resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==} + + pump@3.0.3: + resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + qs@6.14.2: + resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==} + engines: {node: '>=0.6'} + + querystringify@2.2.0: + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readable-stream@4.7.0: + resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + readdir-glob@1.1.3: + resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + + rollup@4.57.1: + resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + rrweb-cssom@0.6.0: + resolution: {integrity: sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + + simple-update-notifier@2.0.0: + resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} + engines: {node: '>=10'} + + socket.io-adapter@2.5.6: + resolution: {integrity: sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==} + + socket.io-parser@4.2.5: + resolution: {integrity: sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==} + engines: {node: '>=10.0.0'} + + socket.io@4.8.3: + resolution: {integrity: sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==} + engines: {node: '>=10.2.0'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + streamsearch@1.1.0: + resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} + engines: {node: '>=10.0.0'} + + streamx@2.23.0: + resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.1.2: + resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} + engines: {node: '>=12'} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + + superagent@10.3.0: + resolution: {integrity: sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==} + engines: {node: '>=14.18.0'} + + supertest@7.2.2: + resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==} + engines: {node: '>=14.18.0'} + + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tar-fs@2.1.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + tar-stream@3.1.7: + resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} + + text-decoder@1.2.3: + resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.0.3: + resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + engines: {node: '>=14.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + touch@3.1.1: + resolution: {integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==} + hasBin: true + + tough-cookie@4.1.4: + resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} + engines: {node: '>=6'} + + tr46@4.1.1: + resolution: {integrity: sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==} + engines: {node: '>=14'} + + ts-node@10.9.2: + resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} + hasBin: true + 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 + + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + + typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undefsafe@2.0.5: + resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==} + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + universalify@0.2.0: + resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} + engines: {node: '>= 4.0.0'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + url-parse@1.5.10: + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + uuid@13.0.0: + resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==} + hasBin: true + + v8-compile-cache-lib@3.0.1: + resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vite@7.3.1: + resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.0.18: + resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.0.18 + '@vitest/browser-preview': 4.0.18 + '@vitest/browser-webdriverio': 4.0.18 + '@vitest/ui': 4.0.18 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@4.0.0: + resolution: {integrity: sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==} + engines: {node: '>=14'} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@2.0.0: + resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==} + engines: {node: '>=12'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + + whatwg-url@12.0.1: + resolution: {integrity: sha512-Ed/LrqB8EPlGxjS+TrsXcpUond1mhccS3pchLhzSgPCnTimUCKj3IZE75pAs5m6heB2U2TMerKFUXheyHY+VDQ==} + engines: {node: '>=14'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + ws@8.19.0: + resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@4.0.0: + resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} + engines: {node: '>=12'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + + yn@3.1.1: + resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} + engines: {node: '>=6'} + + zip-stream@6.0.1: + resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} + engines: {node: '>= 14'} + + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + +snapshots: + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@esbuild/aix-ppc64@0.27.3': + optional: true + + '@esbuild/android-arm64@0.27.3': + optional: true + + '@esbuild/android-arm@0.27.3': + optional: true + + '@esbuild/android-x64@0.27.3': + optional: true + + '@esbuild/darwin-arm64@0.27.3': + optional: true + + '@esbuild/darwin-x64@0.27.3': + optional: true + + '@esbuild/freebsd-arm64@0.27.3': + optional: true + + '@esbuild/freebsd-x64@0.27.3': + optional: true + + '@esbuild/linux-arm64@0.27.3': + optional: true + + '@esbuild/linux-arm@0.27.3': + optional: true + + '@esbuild/linux-ia32@0.27.3': + optional: true + + '@esbuild/linux-loong64@0.27.3': + optional: true + + '@esbuild/linux-mips64el@0.27.3': + optional: true + + '@esbuild/linux-ppc64@0.27.3': + optional: true + + '@esbuild/linux-riscv64@0.27.3': + optional: true + + '@esbuild/linux-s390x@0.27.3': + optional: true + + '@esbuild/linux-x64@0.27.3': + optional: true + + '@esbuild/netbsd-arm64@0.27.3': + optional: true + + '@esbuild/netbsd-x64@0.27.3': + optional: true + + '@esbuild/openbsd-arm64@0.27.3': + optional: true + + '@esbuild/openbsd-x64@0.27.3': + optional: true + + '@esbuild/openharmony-arm64@0.27.3': + optional: true + + '@esbuild/sunos-x64@0.27.3': + optional: true + + '@esbuild/win32-arm64@0.27.3': + optional: true + + '@esbuild/win32-ia32@0.27.3': + optional: true + + '@esbuild/win32-x64@0.27.3': + optional: true + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.2 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@noble/hashes@1.8.0': {} + + '@paralleldrive/cuid2@2.3.1': + dependencies: + '@noble/hashes': 1.8.0 + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@prisma/client@5.22.0(prisma@5.22.0)': + optionalDependencies: + prisma: 5.22.0 + + '@prisma/debug@5.22.0': {} + + '@prisma/engines-version@5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2': {} + + '@prisma/engines@5.22.0': + dependencies: + '@prisma/debug': 5.22.0 + '@prisma/engines-version': 5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2 + '@prisma/fetch-engine': 5.22.0 + '@prisma/get-platform': 5.22.0 + + '@prisma/fetch-engine@5.22.0': + dependencies: + '@prisma/debug': 5.22.0 + '@prisma/engines-version': 5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2 + '@prisma/get-platform': 5.22.0 + + '@prisma/get-platform@5.22.0': + dependencies: + '@prisma/debug': 5.22.0 + + '@rollup/rollup-android-arm-eabi@4.57.1': + optional: true + + '@rollup/rollup-android-arm64@4.57.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.57.1': + optional: true + + '@rollup/rollup-darwin-x64@4.57.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.57.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.57.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.57.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.57.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.57.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.57.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.57.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.57.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.57.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.57.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.57.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.57.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.57.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.57.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.57.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.57.1': + optional: true + + '@socket.io/component-emitter@3.1.2': {} + + '@standard-schema/spec@1.1.0': {} + + '@tootallnate/once@2.0.0': {} + + '@tsconfig/node10@1.0.12': {} + + '@tsconfig/node12@1.0.11': {} + + '@tsconfig/node14@1.0.3': {} + + '@tsconfig/node16@1.0.4': {} + + '@types/archiver@7.0.0': + dependencies: + '@types/readdir-glob': 1.1.5 + + '@types/bcrypt@6.0.0': + dependencies: + '@types/node': 24.10.13 + + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 24.10.13 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 24.10.13 + + '@types/cookiejar@2.1.5': {} + + '@types/cors@2.8.19': + dependencies: + '@types/node': 24.10.13 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + + '@types/express-serve-static-core@5.1.1': + dependencies: + '@types/node': 24.10.13 + '@types/qs': 6.14.0 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@5.0.6': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 5.1.1 + '@types/serve-static': 2.2.0 + + '@types/http-errors@2.0.5': {} + + '@types/jsdom@21.1.7': + dependencies: + '@types/node': 24.10.13 + '@types/tough-cookie': 4.0.5 + parse5: 7.3.0 + + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 24.10.13 + + '@types/methods@1.1.4': {} + + '@types/ms@2.1.0': {} + + '@types/multer@2.0.0': + dependencies: + '@types/express': 5.0.6 + + '@types/node@24.10.13': + dependencies: + undici-types: 7.16.0 + + '@types/qs@6.14.0': {} + + '@types/range-parser@1.2.7': {} + + '@types/readdir-glob@1.1.5': + dependencies: + '@types/node': 24.10.13 + + '@types/send@1.2.1': + dependencies: + '@types/node': 24.10.13 + + '@types/serve-static@2.2.0': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 24.10.13 + + '@types/socket.io@3.0.2': + dependencies: + socket.io: 4.8.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@types/superagent@8.1.9': + dependencies: + '@types/cookiejar': 2.1.5 + '@types/methods': 1.1.4 + '@types/node': 24.10.13 + form-data: 4.0.5 + + '@types/supertest@6.0.3': + dependencies: + '@types/methods': 1.1.4 + '@types/superagent': 8.1.9 + + '@types/tough-cookie@4.0.5': {} + + '@types/trusted-types@2.0.7': + optional: true + + '@types/uuid@10.0.0': {} + + '@vitest/expect@4.0.18': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + chai: 6.2.2 + tinyrainbow: 3.0.3 + + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@24.10.13))': + dependencies: + '@vitest/spy': 4.0.18 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@24.10.13) + + '@vitest/pretty-format@4.0.18': + dependencies: + tinyrainbow: 3.0.3 + + '@vitest/runner@4.0.18': + dependencies: + '@vitest/utils': 4.0.18 + pathe: 2.0.3 + + '@vitest/snapshot@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.0.18': {} + + '@vitest/utils@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + tinyrainbow: 3.0.3 + + abab@2.0.6: {} + + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + + acorn-walk@8.3.4: + dependencies: + acorn: 8.15.0 + + acorn@8.15.0: {} + + agent-base@6.0.2: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + + append-field@1.0.0: {} + + archiver-utils@5.0.2: + dependencies: + glob: 10.5.0 + graceful-fs: 4.2.11 + is-stream: 2.0.1 + lazystream: 1.0.1 + lodash: 4.17.23 + normalize-path: 3.0.0 + readable-stream: 4.7.0 + + archiver@7.0.1: + dependencies: + archiver-utils: 5.0.2 + async: 3.2.6 + buffer-crc32: 1.0.0 + readable-stream: 4.7.0 + readdir-glob: 1.1.3 + tar-stream: 3.1.7 + zip-stream: 6.0.1 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + arg@4.1.3: {} + + asap@2.0.6: {} + + assertion-error@2.0.1: {} + + async@3.2.6: {} + + asynckit@0.4.0: {} + + b4a@1.7.3: {} + + balanced-match@1.0.2: {} + + bare-events@2.8.2: {} + + base64-js@1.5.1: {} + + base64id@2.0.0: {} + + bcrypt@6.0.0: + dependencies: + node-addon-api: 8.5.0 + node-gyp-build: 4.8.4 + + better-sqlite3@12.6.2: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + + binary-extensions@2.3.0: {} + + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3(supports-color@5.5.0) + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.14.2 + raw-body: 3.0.2 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + buffer-crc32@1.0.0: {} + + buffer-equal-constant-time@1.0.1: {} + + buffer-from@1.1.2: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + busboy@1.6.0: + dependencies: + streamsearch: 1.1.0 + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + chai@6.2.2: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + chownr@1.1.4: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + component-emitter@1.3.1: {} + + compress-commons@6.0.2: + dependencies: + crc-32: 1.2.2 + crc32-stream: 6.0.0 + is-stream: 2.0.1 + normalize-path: 3.0.0 + readable-stream: 4.7.0 + + concat-map@0.0.1: {} + + concat-stream@2.0.0: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 3.6.2 + typedarray: 0.0.6 + + content-disposition@1.0.1: {} + + content-type@1.0.5: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cookiejar@2.1.4: {} + + core-util-is@1.0.3: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + crc-32@1.2.2: {} + + crc32-stream@6.0.0: + dependencies: + crc-32: 1.2.2 + readable-stream: 4.7.0 + + create-require@1.1.1: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + cssstyle@3.0.0: + dependencies: + rrweb-cssom: 0.6.0 + + data-urls@4.0.0: + dependencies: + abab: 2.0.6 + whatwg-mimetype: 3.0.0 + whatwg-url: 12.0.1 + + debug@4.4.3(supports-color@5.5.0): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 5.5.0 + + decimal.js@10.6.0: {} + + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + deep-extend@0.6.0: {} + + delayed-stream@1.0.0: {} + + depd@2.0.0: {} + + detect-libc@2.1.2: {} + + dezalgo@1.0.4: + dependencies: + asap: 2.0.6 + wrappy: 1.0.2 + + diff@4.0.4: {} + + domexception@4.0.0: + dependencies: + webidl-conversions: 7.0.0 + + dompurify@3.3.1: + optionalDependencies: + '@types/trusted-types': 2.0.7 + + dotenv@17.2.4: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + eastasianwidth@0.2.0: {} + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + ee-first@1.1.1: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + encodeurl@2.0.0: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + engine.io-parser@5.2.3: {} + + engine.io@6.6.5: + dependencies: + '@types/cors': 2.8.19 + '@types/node': 24.10.13 + accepts: 1.3.8 + base64id: 2.0.0 + cookie: 0.7.2 + cors: 2.8.6 + debug: 4.4.3(supports-color@5.5.0) + engine.io-parser: 5.2.3 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + entities@6.0.1: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@1.7.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + esbuild@0.27.3: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.3 + '@esbuild/android-arm': 0.27.3 + '@esbuild/android-arm64': 0.27.3 + '@esbuild/android-x64': 0.27.3 + '@esbuild/darwin-arm64': 0.27.3 + '@esbuild/darwin-x64': 0.27.3 + '@esbuild/freebsd-arm64': 0.27.3 + '@esbuild/freebsd-x64': 0.27.3 + '@esbuild/linux-arm': 0.27.3 + '@esbuild/linux-arm64': 0.27.3 + '@esbuild/linux-ia32': 0.27.3 + '@esbuild/linux-loong64': 0.27.3 + '@esbuild/linux-mips64el': 0.27.3 + '@esbuild/linux-ppc64': 0.27.3 + '@esbuild/linux-riscv64': 0.27.3 + '@esbuild/linux-s390x': 0.27.3 + '@esbuild/linux-x64': 0.27.3 + '@esbuild/netbsd-arm64': 0.27.3 + '@esbuild/netbsd-x64': 0.27.3 + '@esbuild/openbsd-arm64': 0.27.3 + '@esbuild/openbsd-x64': 0.27.3 + '@esbuild/openharmony-arm64': 0.27.3 + '@esbuild/sunos-x64': 0.27.3 + '@esbuild/win32-arm64': 0.27.3 + '@esbuild/win32-ia32': 0.27.3 + '@esbuild/win32-x64': 0.27.3 + + escape-html@1.0.3: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + etag@1.8.1: {} + + event-target-shim@5.0.1: {} + + events-universal@1.0.1: + dependencies: + bare-events: 2.8.2 + transitivePeerDependencies: + - bare-abort-controller + + events@3.3.0: {} + + expand-template@2.0.3: {} + + expect-type@1.3.0: {} + + express-rate-limit@8.2.1(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.0.1 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.0.1 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3(supports-color@5.5.0) + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.14.2 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-fifo@1.3.2: {} + + fast-safe-stringify@2.1.1: {} + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + file-uri-to-path@1.0.0: {} + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + formidable@3.5.4: + dependencies: + '@paralleldrive/cuid2': 2.3.1 + dezalgo: 1.0.4 + once: 1.4.0 + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + fs-constants@1.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + 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 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + github-from-package@0.0.0: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + has-flag@3.0.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + helmet@8.1.0: {} + + html-encoding-sniffer@3.0.0: + dependencies: + whatwg-encoding: 2.0.0 + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + http-proxy-agent@5.0.0: + dependencies: + '@tootallnate/once': 2.0.0 + agent-base: 6.0.2 + debug: 4.4.3(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + ignore-by-default@1.0.1: {} + + immediate@3.0.6: {} + + inherits@2.0.4: {} + + ini@1.3.8: {} + + ip-address@10.0.1: {} + + ipaddr.js@1.9.1: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + is-potential-custom-element-name@1.0.1: {} + + is-promise@4.0.0: {} + + is-stream@2.0.1: {} + + isarray@1.0.0: {} + + isexe@2.0.0: {} + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jose@4.15.9: {} + + jsdom@22.1.0: + dependencies: + abab: 2.0.6 + cssstyle: 3.0.0 + data-urls: 4.0.0 + decimal.js: 10.6.0 + domexception: 4.0.0 + form-data: 4.0.5 + html-encoding-sniffer: 3.0.0 + http-proxy-agent: 5.0.0 + https-proxy-agent: 5.0.1 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.23 + parse5: 7.3.0 + rrweb-cssom: 0.6.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 4.1.4 + w3c-xmlserializer: 4.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 2.0.0 + whatwg-mimetype: 3.0.0 + whatwg-url: 12.0.1 + ws: 8.19.0 + xml-name-validator: 4.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + 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.1.1 + ms: 2.1.3 + semver: 7.7.4 + + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + + lazystream@1.0.1: + dependencies: + readable-stream: 2.3.8 + + lie@3.3.0: + dependencies: + immediate: 3.0.6 + + 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.1.1: {} + + lodash@4.17.23: {} + + lru-cache@10.4.3: {} + + lru-cache@6.0.0: + dependencies: + yallist: 4.0.0 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + make-error@1.3.6: {} + + math-intrinsics@1.1.0: {} + + media-typer@0.3.0: {} + + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + + methods@1.1.2: {} + + mime-db@1.52.0: {} + + mime-db@1.54.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mime@2.6.0: {} + + mimic-response@3.1.0: {} + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.12 + + minimatch@5.1.6: + dependencies: + brace-expansion: 2.0.2 + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.2 + + minimist@1.2.8: {} + + minipass@7.1.2: {} + + mkdirp-classic@0.5.3: {} + + mkdirp@0.5.6: + dependencies: + minimist: 1.2.8 + + ms@2.1.3: {} + + multer@2.0.2: + 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 + + nanoid@3.3.11: {} + + napi-build-utils@2.0.0: {} + + negotiator@0.6.3: {} + + negotiator@1.0.0: {} + + node-abi@3.87.0: + dependencies: + semver: 7.7.4 + + node-addon-api@8.5.0: {} + + node-gyp-build@4.8.4: {} + + nodemon@3.1.11: + dependencies: + chokidar: 3.6.0 + debug: 4.4.3(supports-color@5.5.0) + ignore-by-default: 1.0.1 + minimatch: 3.1.2 + pstree.remy: 1.1.8 + semver: 7.7.4 + simple-update-notifier: 2.0.0 + supports-color: 5.5.0 + touch: 3.1.1 + undefsafe: 2.0.5 + + normalize-path@3.0.0: {} + + nwsapi@2.2.23: {} + + object-assign@4.1.1: {} + + object-hash@2.2.0: {} + + object-inspect@1.13.4: {} + + obug@2.1.1: {} + + oidc-token-hash@5.2.0: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + openid-client@5.7.1: + dependencies: + jose: 4.15.9 + lru-cache: 6.0.0 + object-hash: 2.2.0 + oidc-token-hash: 5.2.0 + + package-json-from-dist@1.0.1: {} + + pako@1.0.11: {} + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + parseurl@1.3.3: {} + + path-key@3.1.1: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + + path-to-regexp@8.3.0: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + picomatch@4.0.3: {} + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.87.0 + pump: 3.0.3 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.4 + tunnel-agent: 0.6.0 + + prisma@5.22.0: + dependencies: + '@prisma/engines': 5.22.0 + optionalDependencies: + fsevents: 2.3.3 + + process-nextick-args@2.0.1: {} + + process@0.11.10: {} + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + psl@1.15.0: + dependencies: + punycode: 2.3.1 + + pstree.remy@1.1.8: {} + + pump@3.0.3: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + punycode@2.3.1: {} + + qs@6.14.2: + dependencies: + side-channel: 1.1.0 + + querystringify@2.2.0: {} + + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readable-stream@4.7.0: + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + + readdir-glob@1.1.3: + dependencies: + minimatch: 5.1.6 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + + requires-port@1.0.0: {} + + rollup@4.57.1: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.57.1 + '@rollup/rollup-android-arm64': 4.57.1 + '@rollup/rollup-darwin-arm64': 4.57.1 + '@rollup/rollup-darwin-x64': 4.57.1 + '@rollup/rollup-freebsd-arm64': 4.57.1 + '@rollup/rollup-freebsd-x64': 4.57.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.57.1 + '@rollup/rollup-linux-arm-musleabihf': 4.57.1 + '@rollup/rollup-linux-arm64-gnu': 4.57.1 + '@rollup/rollup-linux-arm64-musl': 4.57.1 + '@rollup/rollup-linux-loong64-gnu': 4.57.1 + '@rollup/rollup-linux-loong64-musl': 4.57.1 + '@rollup/rollup-linux-ppc64-gnu': 4.57.1 + '@rollup/rollup-linux-ppc64-musl': 4.57.1 + '@rollup/rollup-linux-riscv64-gnu': 4.57.1 + '@rollup/rollup-linux-riscv64-musl': 4.57.1 + '@rollup/rollup-linux-s390x-gnu': 4.57.1 + '@rollup/rollup-linux-x64-gnu': 4.57.1 + '@rollup/rollup-linux-x64-musl': 4.57.1 + '@rollup/rollup-openbsd-x64': 4.57.1 + '@rollup/rollup-openharmony-arm64': 4.57.1 + '@rollup/rollup-win32-arm64-msvc': 4.57.1 + '@rollup/rollup-win32-ia32-msvc': 4.57.1 + '@rollup/rollup-win32-x64-gnu': 4.57.1 + '@rollup/rollup-win32-x64-msvc': 4.57.1 + fsevents: 2.3.3 + + router@2.2.0: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.3.0 + transitivePeerDependencies: + - supports-color + + rrweb-cssom@0.6.0: {} + + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + semver@7.7.4: {} + + send@1.2.1: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setimmediate@1.0.5: {} + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + siginfo@2.0.0: {} + + signal-exit@4.1.0: {} + + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + + simple-update-notifier@2.0.0: + dependencies: + semver: 7.7.4 + + socket.io-adapter@2.5.6: + dependencies: + debug: 4.4.3(supports-color@5.5.0) + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + socket.io-parser@4.2.5: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.3(supports-color@5.5.0) + transitivePeerDependencies: + - supports-color + + socket.io@4.8.3: + dependencies: + accepts: 1.3.8 + base64id: 2.0.0 + cors: 2.8.6 + debug: 4.4.3(supports-color@5.5.0) + engine.io: 6.6.5 + socket.io-adapter: 2.5.6 + socket.io-parser: 4.2.5 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + statuses@2.0.2: {} + + std-env@3.10.0: {} + + streamsearch@1.1.0: {} + + streamx@2.23.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.3 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.2 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.1.2: + dependencies: + ansi-regex: 6.2.2 + + strip-json-comments@2.0.1: {} + + superagent@10.3.0: + dependencies: + component-emitter: 1.3.1 + cookiejar: 2.1.4 + debug: 4.4.3(supports-color@5.5.0) + fast-safe-stringify: 2.1.1 + form-data: 4.0.5 + formidable: 3.5.4 + methods: 1.1.2 + mime: 2.6.0 + qs: 6.14.2 + transitivePeerDependencies: + - supports-color + + supertest@7.2.2: + dependencies: + cookie-signature: 1.2.2 + methods: 1.1.2 + superagent: 10.3.0 + transitivePeerDependencies: + - supports-color + + supports-color@5.5.0: + dependencies: + has-flag: 3.0.0 + + symbol-tree@3.2.4: {} + + tar-fs@2.1.4: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.3 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + tar-stream@3.1.7: + dependencies: + b4a: 1.7.3 + fast-fifo: 1.3.2 + streamx: 2.23.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + text-decoder@1.2.3: + dependencies: + b4a: 1.7.3 + transitivePeerDependencies: + - react-native-b4a + + tinybench@2.9.0: {} + + tinyexec@1.0.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tinyrainbow@3.0.3: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + touch@3.1.1: {} + + tough-cookie@4.1.4: + dependencies: + psl: 1.15.0 + punycode: 2.3.1 + universalify: 0.2.0 + url-parse: 1.5.10 + + tr46@4.1.1: + dependencies: + punycode: 2.3.1 + + ts-node@10.9.2(@types/node@24.10.13)(typescript@5.9.3): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.12 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 24.10.13 + acorn: 8.15.0 + acorn-walk: 8.3.4 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.4 + make-error: 1.3.6 + typescript: 5.9.3 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.2 + + typedarray@0.0.6: {} + + typescript@5.9.3: {} + + undefsafe@2.0.5: {} + + undici-types@7.16.0: {} + + universalify@0.2.0: {} + + unpipe@1.0.0: {} + + url-parse@1.5.10: + dependencies: + querystringify: 2.2.0 + requires-port: 1.0.0 + + util-deprecate@1.0.2: {} + + uuid@13.0.0: {} + + v8-compile-cache-lib@3.0.1: {} + + vary@1.1.2: {} + + vite@7.3.1(@types/node@24.10.13): + dependencies: + esbuild: 0.27.3 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.57.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 24.10.13 + fsevents: 2.3.3 + + vitest@4.0.18(@types/node@24.10.13)(jsdom@22.1.0): + dependencies: + '@vitest/expect': 4.0.18 + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@24.10.13)) + '@vitest/pretty-format': 4.0.18 + '@vitest/runner': 4.0.18 + '@vitest/snapshot': 4.0.18 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + es-module-lexer: 1.7.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 7.3.1(@types/node@24.10.13) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.10.13 + jsdom: 22.1.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + + w3c-xmlserializer@4.0.0: + dependencies: + xml-name-validator: 4.0.0 + + webidl-conversions@7.0.0: {} + + whatwg-encoding@2.0.0: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@3.0.0: {} + + whatwg-url@12.0.1: + dependencies: + tr46: 4.1.1 + webidl-conversions: 7.0.0 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.1.2 + + wrappy@1.0.2: {} + + ws@8.18.3: {} + + ws@8.19.0: {} + + xml-name-validator@4.0.0: {} + + xmlchars@2.2.0: {} + + xtend@4.0.2: {} + + yallist@4.0.0: {} + + yn@3.1.1: {} + + zip-stream@6.0.1: + dependencies: + archiver-utils: 5.0.2 + compress-commons: 6.0.2 + readable-stream: 4.7.0 + + zod@4.3.6: {} diff --git a/backend/prisma/migrations/20260124145151_add_user_auth/migration.sql b/backend/prisma/migrations/20260124145151_add_user_auth/migration.sql new file mode 100644 index 00000000..68572415 --- /dev/null +++ b/backend/prisma/migrations/20260124145151_add_user_auth/migration.sql @@ -0,0 +1,96 @@ +-- NOTE: +-- This migration assigns all pre-existing data to a bootstrap admin user so that +-- upgrading an existing (non-empty) database doesn't fail and the data remains accessible. +-- The bootstrap admin user starts inactive and must be activated via the app's +-- initial registration flow. + +-- Constants +-- Keep in sync with backend/src/auth.ts +-- (SQLite doesn't support variables; we inline the values instead.) +-- BOOTSTRAP_USER_ID = 'bootstrap-admin' +-- BOOTSTRAP_LIBRARY_ID = 'user_bootstrap-admin' + +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL PRIMARY KEY, + "username" TEXT, + "email" TEXT NOT NULL, + "passwordHash" TEXT NOT NULL, + "name" TEXT NOT NULL, + "role" TEXT NOT NULL DEFAULT 'USER', + "mustResetPassword" BOOLEAN NOT NULL DEFAULT false, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "SystemConfig" ( + "id" TEXT NOT NULL PRIMARY KEY DEFAULT 'default', + "registrationEnabled" BOOLEAN NOT NULL DEFAULT false, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- Bootstrap state: +-- - Insert a singleton config row (registration disabled by default) +-- - Insert an inactive bootstrap admin user and assign all existing data to it +INSERT INTO "SystemConfig" ("id", "registrationEnabled", "createdAt", "updatedAt") +VALUES ('default', false, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); + +INSERT INTO "User" ("id", "username", "email", "passwordHash", "name", "role", "mustResetPassword", "isActive", "createdAt", "updatedAt") +VALUES ('bootstrap-admin', NULL, 'bootstrap@excalidash.local', '', 'Bootstrap Admin', 'ADMIN', true, false, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); + +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_Collection" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "Collection_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_Collection" ("createdAt", "id", "name", "userId", "updatedAt") +SELECT "createdAt", "id", "name", 'bootstrap-admin', "updatedAt" FROM "Collection"; +DROP TABLE "Collection"; +ALTER TABLE "new_Collection" RENAME TO "Collection"; +CREATE TABLE "new_Drawing" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "elements" TEXT NOT NULL, + "appState" TEXT NOT NULL, + "files" TEXT NOT NULL DEFAULT '{}', + "preview" TEXT, + "version" INTEGER NOT NULL DEFAULT 1, + "userId" TEXT NOT NULL, + "collectionId" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "Drawing_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "Drawing_collectionId_fkey" FOREIGN KEY ("collectionId") REFERENCES "Collection" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); +INSERT INTO "new_Drawing" ("appState", "collectionId", "createdAt", "elements", "files", "id", "name", "preview", "userId", "updatedAt", "version") +SELECT "appState", "collectionId", "createdAt", "elements", "files", "id", "name", "preview", 'bootstrap-admin', "updatedAt", "version" FROM "Drawing"; +DROP TABLE "Drawing"; +ALTER TABLE "new_Drawing" RENAME TO "Drawing"; +CREATE TABLE "new_Library" ( + "id" TEXT NOT NULL PRIMARY KEY, + "items" TEXT NOT NULL DEFAULT '[]', + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); +-- Migrate the singleton library to the bootstrap user's library key. +INSERT INTO "new_Library" ("createdAt", "id", "items", "updatedAt") +SELECT "createdAt", 'user_bootstrap-admin', "items", "updatedAt" FROM "Library" WHERE "id" = 'default'; +DROP TABLE "Library"; +ALTER TABLE "new_Library" RENAME TO "Library"; +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); diff --git a/backend/prisma/migrations/20260124152839_add_password_reset_audit_refresh_tokens/migration.sql b/backend/prisma/migrations/20260124152839_add_password_reset_audit_refresh_tokens/migration.sql new file mode 100644 index 00000000..e75a5114 --- /dev/null +++ b/backend/prisma/migrations/20260124152839_add_password_reset_audit_refresh_tokens/migration.sql @@ -0,0 +1,40 @@ +-- CreateTable +CREATE TABLE "PasswordResetToken" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "token" TEXT NOT NULL, + "expiresAt" DATETIME NOT NULL, + "used" BOOLEAN NOT NULL DEFAULT false, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "PasswordResetToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "RefreshToken" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "token" TEXT NOT NULL, + "expiresAt" DATETIME NOT NULL, + "revoked" BOOLEAN NOT NULL DEFAULT false, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "AuditLog" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT, + "action" TEXT NOT NULL, + "resource" TEXT, + "ipAddress" TEXT, + "userAgent" TEXT, + "details" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "AuditLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "PasswordResetToken_token_key" ON "PasswordResetToken"("token"); + +-- CreateIndex +CREATE UNIQUE INDEX "RefreshToken_token_key" ON "RefreshToken"("token"); diff --git a/backend/prisma/migrations/20260206173000_add_auth_enabled_system_config/migration.sql b/backend/prisma/migrations/20260206173000_add_auth_enabled_system_config/migration.sql new file mode 100644 index 00000000..677ca93b --- /dev/null +++ b/backend/prisma/migrations/20260206173000_add_auth_enabled_system_config/migration.sql @@ -0,0 +1,5 @@ +-- Add authEnabled flag to SystemConfig to support single-user mode by default. + +-- SQLite supports simple ADD COLUMN for non-null with default. +ALTER TABLE "SystemConfig" ADD COLUMN "authEnabled" BOOLEAN NOT NULL DEFAULT false; + diff --git a/backend/prisma/migrations/20260206195000_add_auth_login_rate_limit_config/migration.sql b/backend/prisma/migrations/20260206195000_add_auth_login_rate_limit_config/migration.sql new file mode 100644 index 00000000..6c382181 --- /dev/null +++ b/backend/prisma/migrations/20260206195000_add_auth_login_rate_limit_config/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "SystemConfig" ADD COLUMN "authLoginRateLimitEnabled" BOOLEAN NOT NULL DEFAULT 1; +ALTER TABLE "SystemConfig" ADD COLUMN "authLoginRateLimitWindowMs" INTEGER NOT NULL DEFAULT 900000; +ALTER TABLE "SystemConfig" ADD COLUMN "authLoginRateLimitMax" INTEGER NOT NULL DEFAULT 20; + diff --git a/backend/prisma/migrations/20260207000000_add_query_indexes/migration.sql b/backend/prisma/migrations/20260207000000_add_query_indexes/migration.sql new file mode 100644 index 00000000..0e459d81 --- /dev/null +++ b/backend/prisma/migrations/20260207000000_add_query_indexes/migration.sql @@ -0,0 +1,9 @@ +-- Improve dashboard query performance for user-scoped collection and drawing listings. +CREATE INDEX IF NOT EXISTS "Collection_userId_updatedAt_idx" +ON "Collection" ("userId", "updatedAt"); + +CREATE INDEX IF NOT EXISTS "Drawing_userId_updatedAt_idx" +ON "Drawing" ("userId", "updatedAt"); + +CREATE INDEX IF NOT EXISTS "Drawing_userId_collectionId_updatedAt_idx" +ON "Drawing" ("userId", "collectionId", "updatedAt"); diff --git a/backend/prisma/migrations/20260210153000_add_auth_onboarding_completed/migration.sql b/backend/prisma/migrations/20260210153000_add_auth_onboarding_completed/migration.sql new file mode 100644 index 00000000..bb9dd194 --- /dev/null +++ b/backend/prisma/migrations/20260210153000_add_auth_onboarding_completed/migration.sql @@ -0,0 +1,2 @@ +-- Track whether initial auth mode choice has been explicitly completed. +ALTER TABLE "SystemConfig" ADD COLUMN "authOnboardingCompleted" BOOLEAN NOT NULL DEFAULT false; diff --git a/backend/prisma/migrations/20260210190000_add_auth_identity/migration.sql b/backend/prisma/migrations/20260210190000_add_auth_identity/migration.sql new file mode 100644 index 00000000..5c44fa21 --- /dev/null +++ b/backend/prisma/migrations/20260210190000_add_auth_identity/migration.sql @@ -0,0 +1,22 @@ +-- CreateTable +CREATE TABLE "AuthIdentity" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "issuer" TEXT NOT NULL, + "subject" TEXT NOT NULL, + "emailAtLink" TEXT NOT NULL, + "lastLoginAt" DATETIME, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "AuthIdentity_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "AuthIdentity_issuer_subject_key" ON "AuthIdentity"("issuer", "subject"); + +-- CreateIndex +CREATE UNIQUE INDEX "AuthIdentity_provider_userId_key" ON "AuthIdentity"("provider", "userId"); + +-- CreateIndex +CREATE INDEX "AuthIdentity_userId_idx" ON "AuthIdentity"("userId"); diff --git a/backend/prisma/migrations/20260213163825_add_drawing_sharing/migration.sql b/backend/prisma/migrations/20260213163825_add_drawing_sharing/migration.sql new file mode 100644 index 00000000..23392eb6 --- /dev/null +++ b/backend/prisma/migrations/20260213163825_add_drawing_sharing/migration.sql @@ -0,0 +1,42 @@ +-- CreateTable +CREATE TABLE "DrawingShareLink" ( + "id" TEXT NOT NULL PRIMARY KEY, + "drawingId" TEXT NOT NULL, + "role" TEXT NOT NULL, + "token" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "DrawingShareLink_drawingId_fkey" FOREIGN KEY ("drawingId") REFERENCES "Drawing" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "DrawingShareGrant" ( + "id" TEXT NOT NULL PRIMARY KEY, + "drawingId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "shareLinkId" TEXT NOT NULL, + "role" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "DrawingShareGrant_drawingId_fkey" FOREIGN KEY ("drawingId") REFERENCES "Drawing" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "DrawingShareGrant_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "DrawingShareGrant_shareLinkId_fkey" FOREIGN KEY ("shareLinkId") REFERENCES "DrawingShareLink" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "DrawingShareLink_token_key" ON "DrawingShareLink"("token"); + +-- CreateIndex +CREATE INDEX "DrawingShareLink_drawingId_idx" ON "DrawingShareLink"("drawingId"); + +-- CreateIndex +CREATE UNIQUE INDEX "DrawingShareLink_drawingId_role_key" ON "DrawingShareLink"("drawingId", "role"); + +-- CreateIndex +CREATE INDEX "DrawingShareGrant_drawingId_userId_idx" ON "DrawingShareGrant"("drawingId", "userId"); + +-- CreateIndex +CREATE INDEX "DrawingShareGrant_userId_createdAt_idx" ON "DrawingShareGrant"("userId", "createdAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "DrawingShareGrant_drawingId_userId_shareLinkId_key" ON "DrawingShareGrant"("drawingId", "userId", "shareLinkId"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 23da0270..80598fe2 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -12,12 +12,48 @@ datasource db { url = env("DATABASE_URL") } +model User { + id String @id @default(uuid()) + username String? @unique + email String @unique + passwordHash String + name String + role String @default("USER") + mustResetPassword Boolean @default(false) + isActive Boolean @default(true) + authIdentities AuthIdentity[] + drawings Drawing[] + collections Collection[] + passwordResetTokens PasswordResetToken[] + refreshTokens RefreshToken[] + auditLogs AuditLog[] + drawingShareGrants DrawingShareGrant[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model SystemConfig { + id String @id @default("default") + authEnabled Boolean @default(false) + authOnboardingCompleted Boolean @default(false) + registrationEnabled Boolean @default(false) + authLoginRateLimitEnabled Boolean @default(true) + authLoginRateLimitWindowMs Int @default(900000) // 15 minutes + authLoginRateLimitMax Int @default(20) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + model Collection { id String @id @default(uuid()) name String + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) drawings Drawing[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + + @@index([userId, updatedAt]) } model Drawing { @@ -28,15 +64,103 @@ model Drawing { files String @default("{}") // Stored as JSON string preview String? // SVG string for thumbnail version Int @default(1) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) collectionId String? collection Collection? @relation(fields: [collectionId], references: [id]) + shareLinks DrawingShareLink[] + shareGrants DrawingShareGrant[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + + @@index([userId, updatedAt]) + @@index([userId, collectionId, updatedAt]) +} + +model DrawingShareLink { + id String @id @default(uuid()) + drawingId String + drawing Drawing @relation(fields: [drawingId], references: [id], onDelete: Cascade) + role String + token String @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + grants DrawingShareGrant[] + + @@unique([drawingId, role]) + @@index([drawingId]) +} + +model DrawingShareGrant { + id String @id @default(uuid()) + drawingId String + drawing Drawing @relation(fields: [drawingId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + shareLinkId String + shareLink DrawingShareLink @relation(fields: [shareLinkId], references: [id], onDelete: Cascade) + role String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([drawingId, userId, shareLinkId]) + @@index([drawingId, userId]) + @@index([userId, createdAt]) } model Library { - id String @id @default("default") // Singleton pattern - use "default" ID + id String @id // User-specific library ID (e.g., "user_") items String @default("[]") // Stored as JSON string array of library items createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } + +model PasswordResetToken { + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + token String @unique + expiresAt DateTime + used Boolean @default(false) + createdAt DateTime @default(now()) +} + +model RefreshToken { + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + token String @unique + expiresAt DateTime + revoked Boolean @default(false) + createdAt DateTime @default(now()) +} + +model AuditLog { + id String @id @default(uuid()) + userId String? + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + action String // e.g., "login", "login_failed", "password_reset", "password_changed", "drawing_deleted" + resource String? // e.g., "drawing:123", "collection:456" + ipAddress String? + userAgent String? + details String? // JSON string for additional details + createdAt DateTime @default(now()) +} + +model AuthIdentity { + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + provider String + issuer String + subject String + emailAtLink String + lastLoginAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([issuer, subject]) + @@unique([provider, userId]) + @@index([userId]) +} diff --git a/backend/scripts/admin-recover.cjs b/backend/scripts/admin-recover.cjs new file mode 100644 index 00000000..73c70f97 --- /dev/null +++ b/backend/scripts/admin-recover.cjs @@ -0,0 +1,183 @@ +#!/usr/bin/env node + +/** + * CLI admin password recovery for ExcaliDash. + * + * Examples: + * node scripts/admin-recover.cjs --identifier admin@example.com --password "NewStrongPassword!" + * node scripts/admin-recover.cjs --identifier admin@example.com --generate + * + * Notes: + * - Works with SQLite DATABASE_URL (default: file:./prisma/dev.db). + * - Sets the password hash and clears mustResetPassword by default. + * - If there are no active admins, this script can promote the target user to ADMIN. + */ + +require("dotenv").config(); + +const path = require("path"); +process.env.DATABASE_URL = + process.env.DATABASE_URL || + `file:${path.resolve(__dirname, "../prisma/dev.db")}`; + +const { PrismaClient } = require("../src/generated/client"); +const bcrypt = require("bcrypt"); + +const parseArgs = (argv) => { + const args = {}; + for (let i = 0; i < argv.length; i += 1) { + const token = argv[i]; + if (!token.startsWith("--")) continue; + const key = token.slice(2); + const next = argv[i + 1]; + if (!next || next.startsWith("--")) { + args[key] = true; + } else { + args[key] = next; + i += 1; + } + } + return args; +}; + +const generatePassword = () => { + // 24 chars base64url-ish + const buf = require("crypto").randomBytes(18); + return buf.toString("base64").replace(/[+/=]/g, "").slice(0, 24); +}; + +const main = async () => { + const args = parseArgs(process.argv.slice(2)); + + const identifier = typeof args.identifier === "string" ? args.identifier.trim() : ""; + const providedPassword = typeof args.password === "string" ? args.password : null; + const generate = Boolean(args.generate); + const setMustReset = Boolean(args["must-reset"]); + const activate = Boolean(args.activate); + const promote = Boolean(args.promote); + const disableLoginRateLimit = Boolean(args["disable-login-rate-limit"]); + + if (!identifier) { + console.error("Missing --identifier (email or username)."); + process.exitCode = 2; + return; + } + + let newPassword = providedPassword; + if (!newPassword) { + if (!generate) { + console.error('Provide --password "" or pass --generate.'); + process.exitCode = 2; + return; + } + newPassword = generatePassword(); + } + + if (newPassword.length < 8) { + console.error("Password must be at least 8 characters."); + process.exitCode = 2; + return; + } + + const prisma = new PrismaClient(); + + try { + const activeAdminCount = await prisma.user.count({ + where: { role: "ADMIN", isActive: true }, + }); + + const trimmed = identifier.toLowerCase(); + const user = await prisma.user.findFirst({ + where: { + OR: [{ email: trimmed }, { username: identifier }], + }, + select: { + id: true, + email: true, + username: true, + role: true, + isActive: true, + mustResetPassword: true, + }, + }); + + if (!user) { + console.error("User not found:", identifier); + process.exitCode = 1; + return; + } + + const shouldPromote = promote || activeAdminCount === 0; + + if (user.role !== "ADMIN" && !shouldPromote) { + console.error("Target user is not an ADMIN. Refusing to reset password for non-admin user."); + console.error("Tip: pass --promote to promote this user to ADMIN, or use it only when there are 0 active admins."); + process.exitCode = 1; + return; + } + + const saltRounds = 10; + const passwordHash = await bcrypt.hash(newPassword, saltRounds); + + if (disableLoginRateLimit) { + await prisma.systemConfig.upsert({ + where: { id: "default" }, + update: { authLoginRateLimitEnabled: false }, + create: { + id: "default", + authEnabled: true, + registrationEnabled: false, + authLoginRateLimitEnabled: false, + authLoginRateLimitWindowMs: 15 * 60 * 1000, + authLoginRateLimitMax: 20, + }, + }); + } + + const updated = await prisma.user.update({ + where: { id: user.id }, + data: { + passwordHash, + mustResetPassword: setMustReset ? true : false, + isActive: activate ? true : user.isActive, + role: shouldPromote ? "ADMIN" : user.role, + }, + select: { + id: true, + email: true, + username: true, + role: true, + isActive: true, + mustResetPassword: true, + }, + }); + + console.log("Updated admin account:"); + console.log(`- id: ${updated.id}`); + console.log(`- email: ${updated.email}`); + console.log(`- username: ${updated.username || ""}`); + console.log(`- isActive: ${updated.isActive}`); + console.log(`- mustResetPassword: ${updated.mustResetPassword}`); + console.log(`- role: ${updated.role}`); + if (disableLoginRateLimit) { + console.log(""); + console.log("Login rate limiting: DISABLED (SystemConfig.authLoginRateLimitEnabled=false)."); + console.log("Remember to re-enable it from the Admin dashboard after you regain access."); + } + if (generate || !providedPassword) { + console.log(""); + console.log("New password:"); + console.log(newPassword); + } else { + console.log(""); + console.log("Password updated."); + } + } finally { + await prisma.$disconnect().catch(() => {}); + } +}; + +main().catch((err) => { + console.error("Admin recovery failed:", err); + process.exitCode = 1; +}); diff --git a/backend/scripts/predev-migrate.cjs b/backend/scripts/predev-migrate.cjs new file mode 100644 index 00000000..3caf92b9 --- /dev/null +++ b/backend/scripts/predev-migrate.cjs @@ -0,0 +1,138 @@ +/* eslint-disable no-console */ +const { execSync } = require("child_process"); +const fs = require("fs"); +const path = require("path"); + +const backendRoot = path.resolve(__dirname, ".."); + +const resolveDatabaseUrl = (rawUrl) => { + const defaultDbPath = path.resolve(backendRoot, "prisma/dev.db"); + + if (!rawUrl || String(rawUrl).trim().length === 0) { + return `file:${defaultDbPath}`; + } + + if (!String(rawUrl).startsWith("file:")) { + return String(rawUrl); + } + + const filePath = String(rawUrl).replace(/^file:/, ""); + const prismaDir = path.resolve(backendRoot, "prisma"); + const normalizedRelative = filePath.replace(/^\.\/?/, ""); + const hasLeadingPrismaDir = + normalizedRelative === "prisma" || normalizedRelative.startsWith("prisma/"); + + const absolutePath = path.isAbsolute(filePath) + ? filePath + : path.resolve( + hasLeadingPrismaDir ? backendRoot : prismaDir, + normalizedRelative, + ); + + return `file:${absolutePath}`; +}; + +const databaseUrl = resolveDatabaseUrl(process.env.DATABASE_URL); +process.env.DATABASE_URL = databaseUrl; + +const nodeEnv = process.env.NODE_ENV || "development"; + +const runCapture = (cmd) => { + try { + const stdout = execSync(cmd, { + cwd: backendRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, DATABASE_URL: databaseUrl }, + }); + return { ok: true, stdout: stdout || "", stderr: "" }; + } catch (error) { + const err = error; + const stderr = + err && err.stderr + ? Buffer.isBuffer(err.stderr) + ? err.stderr.toString("utf8") + : String(err.stderr) + : ""; + const stdout = + err && err.stdout + ? Buffer.isBuffer(err.stdout) + ? err.stdout.toString("utf8") + : String(err.stdout) + : ""; + return { ok: false, stdout, stderr, error: err }; + } +}; + +const run = (cmd) => { + execSync(cmd, { + cwd: backendRoot, + stdio: "inherit", + env: { ...process.env, DATABASE_URL: databaseUrl }, + }); +}; + +const getDbFilePath = () => { + if (!databaseUrl.startsWith("file:")) return null; + return databaseUrl.replace(/^file:/, ""); +}; + +const backupDbIfPresent = () => { + const dbPath = getDbFilePath(); + if (!dbPath) return null; + if (!fs.existsSync(dbPath)) return null; + + const dir = path.dirname(dbPath); + const base = path.basename(dbPath, path.extname(dbPath)); + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + const backupPath = path.join(dir, `${base}.${stamp}.backup`); + + fs.copyFileSync(dbPath, backupPath); + return backupPath; +}; + +const isNonProd = nodeEnv !== "production"; +const isFileDb = databaseUrl.startsWith("file:"); + +let deploy = runCapture("npx prisma migrate deploy"); + +if (!deploy.ok) { + console.warn( + `[predev] Prisma migrate deploy failed. Attempting pnpm exec...`, + ); + deploy = runCapture("pnpm exec prisma migrate deploy"); +} + +if (deploy.ok) { + if (deploy.stdout) process.stdout.write(deploy.stdout); +} else { + if (deploy.stdout) process.stdout.write(deploy.stdout); + if (deploy.stderr) process.stderr.write(deploy.stderr); + + const stderr = deploy.stderr || ""; + const isP3005 = stderr.includes("P3005"); + + // Common when an older dev.db exists but migrations weren't used previously. + if (isNonProd && isFileDb && isP3005) { + const backupPath = backupDbIfPresent(); + console.warn( + `[predev] Prisma migrate baseline required (P3005). Resetting local SQLite database.\n` + + ` DATABASE_URL=${databaseUrl}\n` + + (backupPath ? ` Backup: ${backupPath}\n` : "") + + ` If you need to preserve local data, restore the backup and baseline manually.`, + ); + + // check for npx, if not present try pnpm exec + try { + console.log( + `[predev] Running: npx prisma migrate reset --force --skip-seed`, + ); + run("npx prisma migrate reset --force --skip-seed"); + } catch { + console.warn(`[predev] npx not found, trying pnpm exec...`); + run("pnpm exec prisma migrate reset --force --skip-seed"); + } + } else { + throw deploy.error; + } +} diff --git a/backend/scripts/simulate-auth-onboarding.cjs b/backend/scripts/simulate-auth-onboarding.cjs new file mode 100755 index 00000000..35d1207a --- /dev/null +++ b/backend/scripts/simulate-auth-onboarding.cjs @@ -0,0 +1,330 @@ +#!/usr/bin/env node + +require("dotenv").config(); + +const path = require("path"); +const { execSync } = require("child_process"); +const { PrismaClient } = require("../src/generated/client"); + +const BOOTSTRAP_USER_ID = "bootstrap-admin"; +const DEFAULT_SYSTEM_CONFIG_ID = "default"; +const backendRoot = path.resolve(__dirname, ".."); + +const resolveDatabaseUrl = (rawUrl) => { + const backendRoot = path.resolve(__dirname, ".."); + const defaultDbPath = path.resolve(backendRoot, "prisma/dev.db"); + + if (!rawUrl || String(rawUrl).trim().length === 0) { + return `file:${defaultDbPath}`; + } + + if (!String(rawUrl).startsWith("file:")) { + return String(rawUrl); + } + + const filePath = String(rawUrl).replace(/^file:/, ""); + const prismaDir = path.resolve(backendRoot, "prisma"); + const normalizedRelative = filePath.replace(/^\.\/?/, ""); + const hasLeadingPrismaDir = + normalizedRelative === "prisma" || normalizedRelative.startsWith("prisma/"); + + const absolutePath = path.isAbsolute(filePath) + ? filePath + : path.resolve(hasLeadingPrismaDir ? backendRoot : prismaDir, normalizedRelative); + + return `file:${absolutePath}`; +}; + +process.env.DATABASE_URL = resolveDatabaseUrl(process.env.DATABASE_URL); + +const parseArgs = (argv) => { + const parsed = { + scenario: "", + dryRun: false, + allowProd: false, + }; + + for (let i = 0; i < argv.length; i += 1) { + const token = argv[i]; + if (token === "--scenario") { + parsed.scenario = String(argv[i + 1] || "").trim().toLowerCase(); + i += 1; + continue; + } + if (token === "--dry-run") { + parsed.dryRun = true; + continue; + } + if (token === "--allow-production") { + parsed.allowProd = true; + continue; + } + if (token === "--help" || token === "-h") { + parsed.help = true; + continue; + } + } + + return parsed; +}; + +const usage = () => { + console.log(`Usage: + node scripts/simulate-auth-onboarding.cjs --scenario fresh + node scripts/simulate-auth-onboarding.cjs --scenario migration + +Options: + --dry-run Show what would change without modifying data + --allow-production Override production safety check (not recommended) + --help, -h Show this help +`); +}; + +const assertScenario = (scenario) => { + if (scenario !== "fresh" && scenario !== "migration") { + throw new Error("Invalid --scenario. Use 'fresh' or 'migration'."); + } +}; + +const nowIso = () => new Date().toISOString(); + +const run = async () => { + const args = parseArgs(process.argv.slice(2)); + if (args.help) { + usage(); + return; + } + + assertScenario(args.scenario); + +const nodeEnv = process.env.NODE_ENV || "development"; + if (nodeEnv === "production" && !args.allowProd) { + throw new Error( + "Refusing to run in production. Pass --allow-production only if you explicitly intend this." + ); + } + + // Keep migration history authoritative to avoid drift between db push and deploy. + // Includes a self-heal path for the known duplicate-column failure on + // 20260210153000_add_auth_onboarding_completed in local dev databases. + if (nodeEnv !== "production") { + const runDeploy = () => + execSync("npx prisma migrate deploy", { + cwd: backendRoot, + stdio: "pipe", + env: { + ...process.env, + DATABASE_URL: process.env.DATABASE_URL, + }, + }); + + try { + runDeploy(); + } catch (error) { + const stdout = + error && error.stdout + ? Buffer.isBuffer(error.stdout) + ? error.stdout.toString("utf8") + : String(error.stdout) + : ""; + const stderr = + error && error.stderr + ? Buffer.isBuffer(error.stderr) + ? error.stderr.toString("utf8") + : String(error.stderr) + : ""; + const combined = `${stdout}\n${stderr}`; + + const canAutoResolve = + combined.includes("Error: P3009") && + combined.includes("20260210153000_add_auth_onboarding_completed") && + combined.includes("duplicate column name: authOnboardingCompleted"); + + if (!canAutoResolve) { + throw error; + } + + execSync( + "npx prisma migrate resolve --applied 20260210153000_add_auth_onboarding_completed", + { + cwd: backendRoot, + stdio: "pipe", + env: { + ...process.env, + DATABASE_URL: process.env.DATABASE_URL, + }, + } + ); + runDeploy(); + } + } + + const prisma = new PrismaClient(); + + try { + const before = { + activeUsers: await prisma.user.count({ where: { isActive: true } }), + users: await prisma.user.count(), + drawings: await prisma.drawing.count(), + collections: await prisma.collection.count(), + auth: await prisma.systemConfig.findUnique({ + where: { id: DEFAULT_SYSTEM_CONFIG_ID }, + select: { + authEnabled: true, + authOnboardingCompleted: true, + registrationEnabled: true, + }, + }), + }; + + console.log(`[simulate-auth-onboarding] DATABASE_URL=${process.env.DATABASE_URL}`); + console.log(`[simulate-auth-onboarding] NODE_ENV=${nodeEnv}`); + console.log(`[simulate-auth-onboarding] scenario=${args.scenario}`); + console.log("[simulate-auth-onboarding] before:", before); + + if (args.dryRun) { + console.log("[simulate-auth-onboarding] dry-run only. No data changed."); + return; + } + + await prisma.$transaction(async (tx) => { + await tx.systemConfig.upsert({ + where: { id: DEFAULT_SYSTEM_CONFIG_ID }, + update: { + authEnabled: false, + authOnboardingCompleted: false, + registrationEnabled: false, + }, + create: { + id: DEFAULT_SYSTEM_CONFIG_ID, + authEnabled: false, + authOnboardingCompleted: false, + registrationEnabled: false, + authLoginRateLimitEnabled: true, + authLoginRateLimitWindowMs: 15 * 60 * 1000, + authLoginRateLimitMax: 20, + }, + }); + + await tx.user.updateMany({ + data: { + isActive: false, + mustResetPassword: true, + }, + }); + + await tx.user.upsert({ + where: { id: BOOTSTRAP_USER_ID }, + update: { + email: "bootstrap@excalidash.local", + username: null, + passwordHash: "", + name: "Bootstrap Admin", + role: "ADMIN", + mustResetPassword: true, + isActive: false, + }, + create: { + id: BOOTSTRAP_USER_ID, + email: "bootstrap@excalidash.local", + username: null, + passwordHash: "", + name: "Bootstrap Admin", + role: "ADMIN", + mustResetPassword: true, + isActive: false, + }, + }); + + if (args.scenario === "fresh") { + await tx.drawing.deleteMany({}); + await tx.collection.deleteMany({}); + await tx.library.deleteMany({}); + await tx.user.deleteMany({ + where: { + id: { + not: BOOTSTRAP_USER_ID, + }, + }, + }); + return; + } + + // Migration simulation: + // 1) Reassign existing data ownership to bootstrap user + // 2) Ensure at least one drawing+collection exists so UI shows migration messaging + await tx.collection.updateMany({ + data: { userId: BOOTSTRAP_USER_ID }, + }); + await tx.drawing.updateMany({ + data: { userId: BOOTSTRAP_USER_ID }, + }); + + const collectionCount = await tx.collection.count(); + let targetCollectionId = null; + + if (collectionCount === 0) { + targetCollectionId = `sim-migration-col-${Date.now()}`; + await tx.collection.create({ + data: { + id: targetCollectionId, + name: "Migrated Collection", + userId: BOOTSTRAP_USER_ID, + }, + }); + } else { + const existing = await tx.collection.findFirst({ + where: { userId: BOOTSTRAP_USER_ID }, + select: { id: true }, + orderBy: { createdAt: "asc" }, + }); + targetCollectionId = existing ? existing.id : null; + } + + const drawingCount = await tx.drawing.count(); + if (drawingCount === 0) { + await tx.drawing.create({ + data: { + id: `sim-migration-draw-${Date.now()}`, + name: "Migrated Drawing", + elements: "[]", + appState: "{}", + files: "{}", + preview: null, + version: 1, + userId: BOOTSTRAP_USER_ID, + collectionId: targetCollectionId, + }, + }); + } + }); + + const after = { + activeUsers: await prisma.user.count({ where: { isActive: true } }), + users: await prisma.user.count(), + drawings: await prisma.drawing.count(), + collections: await prisma.collection.count(), + auth: await prisma.systemConfig.findUnique({ + where: { id: DEFAULT_SYSTEM_CONFIG_ID }, + select: { + authEnabled: true, + authOnboardingCompleted: true, + registrationEnabled: true, + }, + }), + }; + + console.log("[simulate-auth-onboarding] after:", after); + console.log(`[simulate-auth-onboarding] completed at ${nowIso()}`); + console.log( + "[simulate-auth-onboarding] If your backend is already running, wait ~5 seconds (auth cache TTL) or restart before refreshing the UI." + ); + } finally { + await prisma.$disconnect().catch(() => {}); + } +}; + +run().catch((error) => { + console.error("simulate-auth-onboarding failed:", error.message || error); + process.exitCode = 1; +}); diff --git a/backend/src/__tests__/auth-enabled.integration.ts b/backend/src/__tests__/auth-enabled.integration.ts new file mode 100644 index 00000000..87fe864b --- /dev/null +++ b/backend/src/__tests__/auth-enabled.integration.ts @@ -0,0 +1,139 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import request from "supertest"; +import bcrypt from "bcrypt"; +import jwt, { SignOptions } from "jsonwebtoken"; +import { StringValue } from "ms"; +import { PrismaClient } from "../generated/client"; +import { config } from "../config"; +import { getTestPrisma, setupTestDb } from "./testUtils"; + +describe("Auth Enabled Toggle Authorization", () => { + const userAgent = "vitest-auth-enabled"; + let prisma: PrismaClient; + let app: any; + let agent: any; + let csrfHeaderName: string; + let csrfToken: string; + let regularUserToken: string; + let adminUserToken: string; + + beforeAll(async () => { + setupTestDb(); + prisma = getTestPrisma(); + + ({ app } = await import("../index")); + + await prisma.systemConfig.upsert({ + where: { id: "default" }, + update: { + authEnabled: true, + registrationEnabled: false, + }, + create: { + id: "default", + authEnabled: true, + registrationEnabled: false, + }, + }); + + const passwordHash = await bcrypt.hash("password123", 10); + const user = await prisma.user.create({ + data: { + email: "regular-user@test.local", + passwordHash, + name: "Regular User", + role: "USER", + isActive: true, + }, + select: { + id: true, + email: true, + }, + }); + + const signOptions: SignOptions = { + expiresIn: config.jwtAccessExpiresIn as StringValue, + }; + regularUserToken = jwt.sign( + { userId: user.id, email: user.email, type: "access" }, + config.jwtSecret, + signOptions + ); + + const admin = await prisma.user.create({ + data: { + email: "admin-user@test.local", + passwordHash, + name: "Admin User", + role: "ADMIN", + isActive: true, + }, + select: { + id: true, + email: true, + }, + }); + + adminUserToken = jwt.sign( + { userId: admin.id, email: admin.email, type: "access" }, + config.jwtSecret, + signOptions + ); + + agent = request.agent(app); + const csrfRes = await agent + .get("/csrf-token") + .set("User-Agent", userAgent); + csrfHeaderName = csrfRes.body.header; + csrfToken = csrfRes.body.token; + }); + + afterAll(async () => { + await prisma.$disconnect(); + }); + + it("rejects unauthenticated auth-enabled toggle when auth is enabled", async () => { + const response = await agent + .post("/auth/auth-enabled") + .set("User-Agent", userAgent) + .set(csrfHeaderName, csrfToken) + .send({ enabled: false }); + + expect(response.status).toBe(401); + }); + + it("rejects non-admin auth-enabled toggle", async () => { + const response = await agent + .post("/auth/auth-enabled") + .set("User-Agent", userAgent) + .set("Authorization", `Bearer ${regularUserToken}`) + .set(csrfHeaderName, csrfToken) + .send({ enabled: false }); + + expect(response.status).toBe(403); + expect(response.body?.message).toContain("Admin access required"); + }); + + it("applies auth mode change immediately for subsequent requests", async () => { + const warmStatusResponse = await request(app) + .get("/auth/status") + .set("User-Agent", userAgent); + expect(warmStatusResponse.status).toBe(200); + expect(warmStatusResponse.body?.authEnabled).toBe(true); + + const toggleResponse = await agent + .post("/auth/auth-enabled") + .set("User-Agent", userAgent) + .set("Authorization", `Bearer ${adminUserToken}`) + .set(csrfHeaderName, csrfToken) + .send({ enabled: false }); + expect(toggleResponse.status).toBe(200); + expect(toggleResponse.body?.authEnabled).toBe(false); + + const drawingsResponse = await request(app) + .get("/drawings") + .set("User-Agent", userAgent); + expect(drawingsResponse.status).toBe(200); + expect(Array.isArray(drawingsResponse.body?.drawings)).toBe(true); + }); +}); diff --git a/backend/src/__tests__/auth-onboarding.integration.ts b/backend/src/__tests__/auth-onboarding.integration.ts new file mode 100644 index 00000000..54076604 --- /dev/null +++ b/backend/src/__tests__/auth-onboarding.integration.ts @@ -0,0 +1,164 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import request from "supertest"; +import { PrismaClient } from "../generated/client"; +import { getTestPrisma, setupTestDb } from "./testUtils"; +import { BOOTSTRAP_USER_ID } from "../auth/authMode"; + +describe("Auth onboarding decision", () => { + const userAgent = "vitest-auth-onboarding"; + let prisma: PrismaClient; + let app: any; + let agent: any; + let csrfHeaderName: string; + let csrfToken: string; + + beforeAll(async () => { + setupTestDb(); + prisma = getTestPrisma(); + + ({ app } = await import("../index")); + + agent = request.agent(app); + const csrfRes = await agent.get("/csrf-token").set("User-Agent", userAgent); + csrfHeaderName = csrfRes.body.header; + csrfToken = csrfRes.body.token; + }); + + afterAll(async () => { + await prisma.$disconnect(); + }); + + it("reports migration onboarding mode when no active users and legacy data exists", async () => { + await prisma.user.upsert({ + where: { id: BOOTSTRAP_USER_ID }, + update: {}, + create: { + id: BOOTSTRAP_USER_ID, + email: "bootstrap@excalidash.local", + username: null, + passwordHash: "", + name: "Bootstrap Admin", + role: "ADMIN", + mustResetPassword: true, + isActive: false, + }, + }); + + await prisma.systemConfig.upsert({ + where: { id: "default" }, + update: { authEnabled: false, authOnboardingCompleted: false }, + create: { + id: "default", + authEnabled: false, + authOnboardingCompleted: false, + registrationEnabled: false, + }, + }); + + await prisma.collection.upsert({ + where: { id: "legacy-collection" }, + update: {}, + create: { + id: "legacy-collection", + name: "Legacy", + userId: BOOTSTRAP_USER_ID, + }, + }); + + await prisma.drawing.upsert({ + where: { id: "legacy-drawing" }, + update: {}, + create: { + id: "legacy-drawing", + name: "Legacy Drawing", + elements: "[]", + appState: "{}", + files: "{}", + userId: BOOTSTRAP_USER_ID, + collectionId: "legacy-collection", + }, + }); + + const response = await request(app).get("/auth/status").set("User-Agent", userAgent); + + expect(response.status).toBe(200); + expect(response.body?.authEnabled).toBe(false); + expect(response.body?.authOnboardingRequired).toBe(true); + expect(response.body?.authOnboardingMode).toBe("migration"); + }); + + it("persists a single-user onboarding choice", async () => { + await prisma.systemConfig.update({ + where: { id: "default" }, + data: { authEnabled: false, authOnboardingCompleted: false }, + }); + + const choiceResponse = await agent + .post("/auth/onboarding-choice") + .set("User-Agent", userAgent) + .set(csrfHeaderName, csrfToken) + .send({ enableAuth: false }); + + expect(choiceResponse.status).toBe(200); + expect(choiceResponse.body?.authEnabled).toBe(false); + expect(choiceResponse.body?.authOnboardingCompleted).toBe(true); + + const statusResponse = await request(app).get("/auth/status").set("User-Agent", userAgent); + expect(statusResponse.status).toBe(200); + expect(statusResponse.body?.authOnboardingRequired).toBe(false); + }); + + it("enables auth and bootstrap flow from onboarding choice", async () => { + await prisma.drawing.deleteMany({}); + await prisma.collection.deleteMany({ where: { id: { not: `trash:${BOOTSTRAP_USER_ID}` } } }); + await prisma.systemConfig.update({ + where: { id: "default" }, + data: { authEnabled: false, authOnboardingCompleted: false }, + }); + + const choiceResponse = await agent + .post("/auth/onboarding-choice") + .set("User-Agent", userAgent) + .set(csrfHeaderName, csrfToken) + .send({ enableAuth: true }); + + expect(choiceResponse.status).toBe(200); + expect(choiceResponse.body?.authEnabled).toBe(true); + expect(choiceResponse.body?.bootstrapRequired).toBe(true); + expect(choiceResponse.body?.authOnboardingCompleted).toBe(true); + + const statusResponse = await request(app).get("/auth/status").set("User-Agent", userAgent); + expect(statusResponse.status).toBe(200); + expect(statusResponse.body?.authEnabled).toBe(true); + expect(statusResponse.body?.bootstrapRequired).toBe(true); + expect(statusResponse.body?.authOnboardingRequired).toBe(false); + }); + + it("requires CSRF token for bootstrap registration", async () => { + const noCsrfResponse = await agent + .post("/auth/register") + .set("User-Agent", userAgent) + .send({ + email: "bootstrap-admin@test.local", + password: "StrongPass1!", + name: "Bootstrap Admin", + }); + + expect(noCsrfResponse.status).toBe(403); + expect(noCsrfResponse.body?.error).toBe("CSRF token missing"); + + const bootstrapResponse = await agent + .post("/auth/register") + .set("User-Agent", userAgent) + .set(csrfHeaderName, csrfToken) + .send({ + email: "bootstrap-admin@test.local", + password: "StrongPass1!", + name: "Bootstrap Admin", + }); + + expect(bootstrapResponse.status).toBe(201); + expect(bootstrapResponse.body?.bootstrapped).toBe(true); + expect(bootstrapResponse.body?.user?.email).toBe("bootstrap-admin@test.local"); + }); +}); diff --git a/backend/src/__tests__/csrf-cookie-stability.test.ts b/backend/src/__tests__/csrf-cookie-stability.test.ts new file mode 100644 index 00000000..514c4b63 --- /dev/null +++ b/backend/src/__tests__/csrf-cookie-stability.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { createCsrfToken, validateCsrfToken } from "../security"; + +describe("CSRF client identity stability", () => { + it("keeps token validation stable when using cookie-based client IDs", () => { + const cookieClientId = "cookie:fixed-client-id"; + const token = createCsrfToken(cookieClientId); + + expect(validateCsrfToken(cookieClientId, token)).toBe(true); + }); + + it("shows why legacy IP-based IDs are unstable across proxy hops", () => { + const userAgent = "Mozilla/5.0 test"; + const clientIdViaProxyA = `10.0.0.5:${userAgent}`; + const clientIdViaProxyB = `10.0.0.6:${userAgent}`; + const token = createCsrfToken(clientIdViaProxyA); + + expect(validateCsrfToken(clientIdViaProxyB, token)).toBe(false); + }); +}); diff --git a/backend/src/__tests__/drawings.integration.ts b/backend/src/__tests__/drawings.integration.ts index e14d0050..c15ffbc6 100644 --- a/backend/src/__tests__/drawings.integration.ts +++ b/backend/src/__tests__/drawings.integration.ts @@ -267,6 +267,90 @@ describe("Security Sanitization - Image Data URLs", () => { }); }); + describe("sanitizeDrawingData - preview svg handling", () => { + it("should preserve safe SVG layout attributes needed for thumbnail rendering", () => { + const preview = [ + '', + '', + '', + "", + ].join(""); + + const result = sanitizeDrawingData({ + elements: [], + appState: { viewBackgroundColor: "#ffffff" }, + files: {}, + preview, + }); + + expect(result.preview).toContain('viewBox="0 0 728.39453125 606.908203125"'); + expect(result.preview).toContain('preserveAspectRatio="xMidYMid meet"'); + expect(result.preview).toContain('stroke-linecap="round"'); + expect(result.preview).toContain('xmlns="http://www.w3.org/2000/svg"'); + }); + + it("should preserve safe embedded image previews", () => { + const preview = [ + '', + '', + "", + ].join(""); + + const result = sanitizeDrawingData({ + elements: [], + appState: { viewBackgroundColor: "#ffffff" }, + files: {}, + preview, + }); + + expect(result.preview).toContain(" { + const preview = [ + '', + '', + '', + "", + ].join(""); + + const result = sanitizeDrawingData({ + elements: [], + appState: { viewBackgroundColor: "#ffffff" }, + files: {}, + preview, + }); + + expect(result.preview).not.toContain(" { + const preview = [ + '', + '', + '', + "", + '', + "", + ].join(""); + + const result = sanitizeDrawingData({ + elements: [], + appState: { viewBackgroundColor: "#ffffff" }, + files: {}, + preview, + }); + + expect(result.preview).toContain(""); + expect(result.preview).toContain(" { it("should validate drawing with embedded images", () => { const files = createSampleFilesObject(2, "large"); @@ -315,10 +399,11 @@ describe("Security Sanitization - Image Data URLs", () => { // Database integration tests describe("Drawing API - Database Round-Trip", () => { const prisma = getTestPrisma(); + let testUser: { id: string }; beforeAll(async () => { setupTestDb(); - await initTestDb(prisma); + testUser = await initTestDb(prisma); }); afterAll(async () => { @@ -343,6 +428,7 @@ describe("Drawing API - Database Round-Trip", () => { elements: JSON.stringify([]), appState: JSON.stringify({ viewBackgroundColor: "#ffffff" }), files: JSON.stringify(files), + userId: testUser.id, }, }); @@ -381,6 +467,7 @@ describe("Drawing API - Database Round-Trip", () => { elements: JSON.stringify([]), appState: JSON.stringify({}), files: JSON.stringify(files), + userId: testUser.id, }, }); @@ -404,6 +491,7 @@ describe("Drawing API - Database Round-Trip", () => { elements: JSON.stringify([]), appState: JSON.stringify({}), files: JSON.stringify({}), + userId: testUser.id, }, }); diff --git a/backend/src/__tests__/imports-compat.integration.ts b/backend/src/__tests__/imports-compat.integration.ts new file mode 100644 index 00000000..ace4d693 --- /dev/null +++ b/backend/src/__tests__/imports-compat.integration.ts @@ -0,0 +1,448 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import request from "supertest"; +import fs from "fs"; +import path from "path"; +import os from "os"; +import JSZip from "jszip"; +import { getTestPrisma, setupTestDb, cleanupTestDb } from "./testUtils"; + +type LegacyDbOptions = { + tableStyle: "prisma" | "plural-lower"; + includeCollections: boolean; + includeMigrationsTable: boolean; + includeTrashDrawing: boolean; +}; + +const createTempDir = () => fs.mkdtempSync(path.join(os.tmpdir(), "excalidash-legacy-")); + +const openWritableDb = (filePath: string): any => { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { DatabaseSync } = require("node:sqlite") as any; + return new DatabaseSync(filePath, { enableForeignKeyConstraints: false }); + } catch (_err) { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const Database = require("better-sqlite3") as any; + return new Database(filePath); + } +}; + +const createLegacySqliteDb = (opts: LegacyDbOptions): string => { + const dir = createTempDir(); + const filePath = path.join(dir, "legacy-export.db"); + const db = openWritableDb(filePath); + + const tableDrawing = opts.tableStyle === "plural-lower" ? "drawings" : "Drawing"; + const tableCollection = opts.tableStyle === "plural-lower" ? "collections" : "Collection"; + + try { + if (opts.includeCollections) { + db.exec(` + CREATE TABLE "${tableCollection}" ( + id TEXT PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + createdAt TEXT, + updatedAt TEXT + ); + `); + db.prepare(`INSERT INTO "${tableCollection}" (id, name, createdAt, updatedAt) VALUES (?, ?, ?, ?)`).run( + "legacy-collection-1", + "Legacy Collection", + new Date("2024-01-01T00:00:00.000Z").toISOString(), + new Date("2024-01-02T00:00:00.000Z").toISOString(), + ); + } + + db.exec(` + CREATE TABLE "${tableDrawing}" ( + id TEXT PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + elements TEXT NOT NULL, + appState TEXT NOT NULL, + files TEXT, + preview TEXT, + version INTEGER, + collectionId TEXT, + collectionName TEXT, + createdAt TEXT, + updatedAt TEXT + ); + `); + + const now = new Date("2024-01-03T00:00:00.000Z").toISOString(); + const insertDrawing = db.prepare( + `INSERT INTO "${tableDrawing}" + (id, name, elements, appState, files, preview, version, collectionId, collectionName, createdAt, updatedAt) + VALUES + (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ); + + insertDrawing.run( + "legacy-drawing-1", + "Legacy Drawing 1", + JSON.stringify([]), + JSON.stringify({}), + JSON.stringify({}), + null, + 1, + opts.includeCollections ? "legacy-collection-1" : null, + opts.includeCollections ? "Legacy Collection" : null, + now, + now, + ); + + insertDrawing.run( + "legacy-drawing-2", + "Legacy Drawing 2 (unorganized)", + JSON.stringify([]), + JSON.stringify({}), + JSON.stringify({}), + null, + 2, + null, + null, + now, + now, + ); + + if (opts.includeTrashDrawing) { + insertDrawing.run( + "legacy-drawing-trash", + "Legacy Trash Drawing", + JSON.stringify([]), + JSON.stringify({}), + JSON.stringify({}), + null, + 1, + "trash", + "Trash", + now, + now, + ); + } + + if (opts.includeMigrationsTable) { + db.exec(` + CREATE TABLE "_prisma_migrations" ( + id TEXT PRIMARY KEY NOT NULL, + checksum TEXT NOT NULL, + finished_at TEXT, + migration_name TEXT NOT NULL, + logs TEXT, + rolled_back_at TEXT, + started_at TEXT NOT NULL, + applied_steps_count INTEGER NOT NULL DEFAULT 0 + ); + `); + db.prepare( + `INSERT INTO "_prisma_migrations" + (id, checksum, finished_at, migration_name, logs, rolled_back_at, started_at, applied_steps_count) + VALUES + (?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + "m1", + "checksum", + new Date("2024-01-04T00:00:00.000Z").toISOString(), + "20240104000000_initial", + null, + null, + new Date("2024-01-04T00:00:00.000Z").toISOString(), + 1, + ); + } + } finally { + db.close(); + } + + return filePath; +}; + +const createExcalidashArchiveWithDuplicateDrawingIds = async (): Promise => { + const dir = createTempDir(); + const filePath = path.join(dir, "duplicate-drawing-ids.excalidash"); + const zip = new JSZip(); + + const manifest = { + format: "excalidash", + formatVersion: 1, + exportedAt: new Date().toISOString(), + unorganizedFolder: "Unorganized", + collections: [] as any[], + drawings: [ + { + id: "duplicate-drawing-id", + name: "Drawing One", + filePath: "Unorganized/drawing-1.excalidraw", + collectionId: null, + }, + { + id: "duplicate-drawing-id", + name: "Drawing Two", + filePath: "Unorganized/drawing-2.excalidraw", + collectionId: null, + }, + ], + }; + + zip.file("excalidash.manifest.json", JSON.stringify(manifest)); + zip.file( + "Unorganized/drawing-1.excalidraw", + JSON.stringify({ type: "excalidraw", version: 2, source: "test", elements: [], appState: {}, files: {} }) + ); + zip.file( + "Unorganized/drawing-2.excalidraw", + JSON.stringify({ type: "excalidraw", version: 2, source: "test", elements: [], appState: {}, files: {} }) + ); + + const buffer = await zip.generateAsync({ type: "nodebuffer" }); + fs.writeFileSync(filePath, buffer); + return filePath; +}; + +const createLegacySqliteDbWithDuplicateDrawingIds = (): string => { + const dir = createTempDir(); + const filePath = path.join(dir, "legacy-duplicate-ids.db"); + const db = openWritableDb(filePath); + + try { + db.exec(` + CREATE TABLE "Drawing" ( + id TEXT, + name TEXT NOT NULL, + elements TEXT NOT NULL, + appState TEXT NOT NULL, + files TEXT, + preview TEXT, + version INTEGER, + collectionId TEXT, + collectionName TEXT, + createdAt TEXT, + updatedAt TEXT + ); + `); + + const now = new Date("2024-01-03T00:00:00.000Z").toISOString(); + const insertDrawing = db.prepare( + `INSERT INTO "Drawing" + (id, name, elements, appState, files, preview, version, collectionId, collectionName, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ); + + insertDrawing.run( + "legacy-duplicate-id", + "Legacy Drawing A", + JSON.stringify([]), + JSON.stringify({}), + JSON.stringify({}), + null, + 1, + null, + null, + now, + now, + ); + + insertDrawing.run( + "legacy-duplicate-id", + "Legacy Drawing B", + JSON.stringify([]), + JSON.stringify({}), + JSON.stringify({}), + null, + 1, + null, + null, + now, + now, + ); + } finally { + db.close(); + } + + return filePath; +}; + +describe("Import compatibility (legacy exports)", () => { + const uploadsDir = path.resolve(__dirname, "../../uploads"); + const userAgent = "vitest-import-compat"; + let prisma: ReturnType; + let app: any; + let agent: any; + let csrfHeaderName: string; + let csrfToken: string; + + beforeAll(async () => { + setupTestDb(); + prisma = getTestPrisma(); + fs.mkdirSync(uploadsDir, { recursive: true }); + + // Import the server AFTER DATABASE_URL is set by setupTestDb/getTestPrisma. + ({ app } = await import("../index")); + + agent = request.agent(app); + const csrfRes = await agent.get("/csrf-token").set("User-Agent", userAgent); + csrfHeaderName = csrfRes.body.header; + csrfToken = csrfRes.body.token; + expect(typeof csrfHeaderName).toBe("string"); + expect(typeof csrfToken).toBe("string"); + }); + + beforeEach(async () => { + await cleanupTestDb(prisma); + }); + + afterAll(async () => { + await prisma.$disconnect(); + }); + + it("verifies a v0.1.x–v0.3.2-style SQLite export (Drawing/Collection tables) and returns migration info when present", async () => { + const legacyDb = createLegacySqliteDb({ + tableStyle: "prisma", + includeCollections: true, + includeMigrationsTable: true, + includeTrashDrawing: false, + }); + + const res = await agent + .post("/import/sqlite/legacy/verify") + .set("User-Agent", userAgent) + .set(csrfHeaderName, csrfToken) + .attach("db", legacyDb); + + expect(res.status).toBe(200); + expect(res.body.valid).toBe(true); + expect(res.body.drawings).toBe(2); + expect(res.body.collections).toBe(1); + expect(res.body.latestMigration).toBe("20240104000000_initial"); + expect(typeof res.body.currentLatestMigration === "string").toBe(true); + }); + + it("merge-imports a legacy SQLite export into the current account without replacing the database", async () => { + const legacyDb = createLegacySqliteDb({ + tableStyle: "prisma", + includeCollections: true, + includeMigrationsTable: false, + includeTrashDrawing: true, + }); + + const res = await agent + .post("/import/sqlite/legacy") + .set("User-Agent", userAgent) + .set(csrfHeaderName, csrfToken) + .attach("db", legacyDb); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.collections?.created).toBeGreaterThanOrEqual(1); + expect(res.body.drawings?.created).toBeGreaterThanOrEqual(3); + + const importedDrawings = await prisma.drawing.findMany({ + orderBy: { name: "asc" }, + select: { id: true, name: true, collectionId: true, userId: true }, + }); + + // In single-user mode, imports land on the bootstrap acting user. + expect(importedDrawings.every((d) => d.userId === "bootstrap-admin")).toBe(true); + expect(importedDrawings.map((d) => d.id)).toEqual( + expect.arrayContaining(["legacy-drawing-1", "legacy-drawing-2", "legacy-drawing-trash"]) + ); + + const trash = await prisma.collection.findUnique({ + where: { id: "trash:bootstrap-admin" }, + }); + expect(trash).toBeTruthy(); + }); + + it("supports older exports with plural/lowercase table names (drawings/collections)", async () => { + const legacyDb = createLegacySqliteDb({ + tableStyle: "plural-lower", + includeCollections: true, + includeMigrationsTable: false, + includeTrashDrawing: false, + }); + + const verify = await agent + .post("/import/sqlite/legacy/verify") + .set("User-Agent", userAgent) + .set(csrfHeaderName, csrfToken) + .attach("db", legacyDb); + + expect(verify.status).toBe(200); + expect(verify.body.drawings).toBe(2); + expect(verify.body.collections).toBe(1); + + const res = await agent + .post("/import/sqlite/legacy") + .set("User-Agent", userAgent) + .set(csrfHeaderName, csrfToken) + .attach("db", legacyDb); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + }); + + it("fails verification if the legacy DB is missing a Drawing table", async () => { + const dir = createTempDir(); + const filePath = path.join(dir, "invalid.db"); + const db = openWritableDb(filePath); + db.exec(`CREATE TABLE "NotDrawing" (id TEXT PRIMARY KEY NOT NULL);`); + db.close(); + + const res = await agent + .post("/import/sqlite/legacy/verify") + .set("User-Agent", userAgent) + .set(csrfHeaderName, csrfToken) + .attach("db", filePath); + + expect(res.status).toBe(400); + expect(res.body.error).toBe("Invalid legacy DB"); + }); + + it("rejects .excalidash verify when manifest has duplicate drawing IDs", async () => { + const archive = await createExcalidashArchiveWithDuplicateDrawingIds(); + const res = await agent + .post("/import/excalidash/verify") + .set("User-Agent", userAgent) + .set(csrfHeaderName, csrfToken) + .attach("archive", archive); + + expect(res.status).toBe(400); + expect(String(res.body.message || "")).toContain("Duplicate drawing id"); + }); + + it("rejects .excalidash import when manifest has duplicate drawing IDs", async () => { + const archive = await createExcalidashArchiveWithDuplicateDrawingIds(); + const res = await agent + .post("/import/excalidash") + .set("User-Agent", userAgent) + .set(csrfHeaderName, csrfToken) + .attach("archive", archive); + + expect(res.status).toBe(400); + expect(String(res.body.message || "")).toContain("Duplicate drawing id"); + }); + + it("rejects legacy verify when DB has duplicate drawing IDs", async () => { + const legacyDb = createLegacySqliteDbWithDuplicateDrawingIds(); + const res = await agent + .post("/import/sqlite/legacy/verify") + .set("User-Agent", userAgent) + .set(csrfHeaderName, csrfToken) + .attach("db", legacyDb); + + expect(res.status).toBe(400); + expect(String(res.body.message || "")).toContain("Duplicate drawing id"); + }); + + it("rejects legacy import when DB has duplicate drawing IDs", async () => { + const legacyDb = createLegacySqliteDbWithDuplicateDrawingIds(); + const res = await agent + .post("/import/sqlite/legacy") + .set("User-Agent", userAgent) + .set(csrfHeaderName, csrfToken) + .attach("db", legacyDb); + + expect(res.status).toBe(400); + expect(String(res.body.message || "")).toContain("Duplicate drawing id"); + }); +}); diff --git a/backend/src/__tests__/preview-update-regression.test.ts b/backend/src/__tests__/preview-update-regression.test.ts new file mode 100644 index 00000000..bccac61a --- /dev/null +++ b/backend/src/__tests__/preview-update-regression.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { sanitizeDrawingUpdateData } from "../index"; + +describe("sanitizeDrawingUpdateData regression", () => { + it("does not inject empty scene fields for preview-only updates", () => { + const payload: { + preview?: string | null; + elements?: unknown[]; + appState?: Record; + files?: Record; + } = { + preview: "", + }; + + const ok = sanitizeDrawingUpdateData(payload); + expect(ok).toBe(true); + expect(typeof payload.preview).toBe("string"); + expect(String(payload.preview)).toContain(" { + const payload: { + preview?: string | null; + elements?: any[]; + appState?: Record; + files?: Record; + } = { + elements: [ + { + id: "el-1", + type: "rectangle", + x: 0, + y: 0, + width: 100, + height: 100, + version: 1, + versionNonce: 1, + isDeleted: false, + }, + ], + appState: { viewBackgroundColor: "#ffffff" }, + files: {}, + preview: "", + }; + + const ok = sanitizeDrawingUpdateData(payload); + expect(ok).toBe(true); + expect(Array.isArray(payload.elements)).toBe(true); + expect(typeof payload.appState).toBe("object"); + }); +}); + diff --git a/backend/src/__tests__/testUtils.ts b/backend/src/__tests__/testUtils.ts index ee9dbc7f..2a0315e7 100644 --- a/backend/src/__tests__/testUtils.ts +++ b/backend/src/__tests__/testUtils.ts @@ -2,11 +2,53 @@ * Test utilities for backend integration tests */ import { PrismaClient } from "../generated/client"; +import fs from "fs"; import path from "path"; import { execSync } from "child_process"; -// Use a separate test database -const TEST_DB_PATH = path.resolve(__dirname, "../../prisma/test.db"); +// Use a unique test database per test-file import to avoid cross-file contention +// when Vitest runs test files in parallel. +const TEST_DB_FILENAME = `test.${process.pid}.${Math.random().toString(16).slice(2)}.db`; +const TEST_DB_PATH = path.resolve(__dirname, "../../prisma", TEST_DB_FILENAME); +const DB_PUSH_LOCK_PATH = path.resolve(__dirname, "../../prisma/.test-db-push.lock"); + +const sleepSync = (ms: number) => { + const shared = new Int32Array(new SharedArrayBuffer(4)); + Atomics.wait(shared, 0, 0, ms); +}; + +const withDbPushLock = (fn: () => void) => { + const start = Date.now(); + let fd: number | null = null; + while (fd === null) { + try { + fd = fs.openSync(DB_PUSH_LOCK_PATH, "wx"); + fs.writeFileSync(fd, String(process.pid)); + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code !== "EEXIST") throw error; + if (Date.now() - start > 30_000) { + throw new Error("Timed out waiting for Prisma db push lock"); + } + sleepSync(50); + } + } + + try { + fn(); + } finally { + try { + fs.closeSync(fd); + } catch { + // ignore + } + try { + fs.unlinkSync(DB_PUSH_LOCK_PATH); + } catch { + // ignore + } + } +}; /** * Get a test Prisma client pointing to the test database @@ -32,10 +74,19 @@ export const setupTestDb = () => { // Run Prisma migrations to create the test database try { - execSync("npx prisma db push --skip-generate", { - cwd: path.resolve(__dirname, "../../"), - env: { ...process.env, DATABASE_URL: databaseUrl }, - stdio: "pipe", + withDbPushLock(() => { + execSync("npx prisma db push --skip-generate --force-reset", { + cwd: path.resolve(__dirname, "../../"), + env: { + ...process.env, + DATABASE_URL: databaseUrl, + // Work around Prisma schema engine failures on this repo's schema + // (seen as a blank "Schema engine error:" from `prisma db push`). + // `RUST_LOG=info` reliably avoids the failure mode. + RUST_LOG: "info", + }, + stdio: "pipe", + }); }); } catch (error) { console.error("Failed to setup test database:", error); @@ -47,10 +98,26 @@ export const setupTestDb = () => { * Clean up the test database between tests */ export const cleanupTestDb = async (prisma: PrismaClient) => { - // Delete all drawings and collections (except Trash) + // Delete all drawings and collections. await prisma.drawing.deleteMany({}); - await prisma.collection.deleteMany({ - where: { id: { not: "trash" } }, + await prisma.collection.deleteMany({}); +}; + +/** + * Create a test user for testing + */ +export const createTestUser = async (prisma: PrismaClient, email: string = "test@example.com") => { + const bcrypt = require("bcrypt"); + const passwordHash = await bcrypt.hash("testpassword", 10); + + return await prisma.user.upsert({ + where: { email }, + update: {}, + create: { + email, + passwordHash, + name: "Test User", + }, }); }; @@ -58,15 +125,21 @@ export const cleanupTestDb = async (prisma: PrismaClient) => { * Initialize test database with required data */ export const initTestDb = async (prisma: PrismaClient) => { + // Create a test user first + const testUser = await createTestUser(prisma); + const trashCollectionId = `trash:${testUser.id}`; + // Ensure Trash collection exists - const trash = await prisma.collection.findUnique({ - where: { id: "trash" }, + const trash = await prisma.collection.findFirst({ + where: { id: trashCollectionId, userId: testUser.id }, }); if (!trash) { await prisma.collection.create({ - data: { id: "trash", name: "Trash" }, + data: { id: trashCollectionId, name: "Trash", userId: testUser.id }, }); } + + return testUser; }; /** diff --git a/backend/src/__tests__/user-sandboxing.test.ts b/backend/src/__tests__/user-sandboxing.test.ts new file mode 100644 index 00000000..f7a5ccab --- /dev/null +++ b/backend/src/__tests__/user-sandboxing.test.ts @@ -0,0 +1,240 @@ +/** + * Security tests for user data sandboxing + * + * Verifies that: + * 1. Drawings cache keys are scoped by userId (prevents cross-user data leakage) + * 2. Drawing CRUD operations enforce userId filtering + * 3. Collection operations enforce userId filtering + */ + +import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; +import bcrypt from "bcrypt"; +import { + getTestPrisma, + setupTestDb, +} from "./testUtils"; +import { PrismaClient } from "../generated/client"; + +let prisma: PrismaClient; + +// These tests verify the data isolation logic at the database query level +describe("User Data Sandboxing", () => { + let userA: { id: string; email: string }; + let userB: { id: string; email: string }; + + beforeAll(async () => { + setupTestDb(); + prisma = getTestPrisma(); + + // Create two test users + const hashA = await bcrypt.hash("passwordA", 10); + const hashB = await bcrypt.hash("passwordB", 10); + + userA = await prisma.user.upsert({ + where: { email: "usera@test.com" }, + update: {}, + create: { + email: "usera@test.com", + passwordHash: hashA, + name: "User A", + }, + }); + + userB = await prisma.user.upsert({ + where: { email: "userb@test.com" }, + update: {}, + create: { + email: "userb@test.com", + passwordHash: hashB, + name: "User B", + }, + }); + }); + + afterAll(async () => { + await prisma.$disconnect(); + }); + + beforeEach(async () => { + await prisma.drawing.deleteMany({}); + await prisma.collection.deleteMany({}); + }); + + describe("Drawing isolation", () => { + it("should not return User A's drawings when querying as User B", async () => { + // Create a drawing for User A + await prisma.drawing.create({ + data: { + name: "User A Drawing", + elements: "[]", + appState: "{}", + userId: userA.id, + }, + }); + + // Query as User B - should get 0 results + const userBDrawings = await prisma.drawing.findMany({ + where: { userId: userB.id }, + }); + + expect(userBDrawings).toHaveLength(0); + }); + + it("should only return the owning user's drawings", async () => { + // Create drawings for both users + await prisma.drawing.create({ + data: { + name: "User A Drawing", + elements: "[]", + appState: "{}", + userId: userA.id, + }, + }); + + await prisma.drawing.create({ + data: { + name: "User B Drawing", + elements: "[]", + appState: "{}", + userId: userB.id, + }, + }); + + const userADrawings = await prisma.drawing.findMany({ + where: { userId: userA.id }, + }); + const userBDrawings = await prisma.drawing.findMany({ + where: { userId: userB.id }, + }); + + expect(userADrawings).toHaveLength(1); + expect(userADrawings[0].name).toBe("User A Drawing"); + + expect(userBDrawings).toHaveLength(1); + expect(userBDrawings[0].name).toBe("User B Drawing"); + }); + + it("should not allow User B to access User A's drawing by ID", async () => { + const drawing = await prisma.drawing.create({ + data: { + name: "User A Secret Drawing", + elements: "[]", + appState: "{}", + userId: userA.id, + }, + }); + + // Simulate the findFirst query used in GET /drawings/:id + const result = await prisma.drawing.findFirst({ + where: { + id: drawing.id, + userId: userB.id, // User B trying to access + }, + }); + + expect(result).toBeNull(); + }); + }); + + describe("Collection isolation", () => { + it("should not return User A's collections when querying as User B", async () => { + await prisma.collection.create({ + data: { + name: "User A Collection", + userId: userA.id, + }, + }); + + const userBCollections = await prisma.collection.findMany({ + where: { userId: userB.id }, + }); + + expect(userBCollections).toHaveLength(0); + }); + + it("should not allow User B to modify User A's collection", async () => { + const collection = await prisma.collection.create({ + data: { + name: "User A Collection", + userId: userA.id, + }, + }); + + // Simulate the findFirst query used in PUT /collections/:id + const result = await prisma.collection.findFirst({ + where: { + id: collection.id, + userId: userB.id, + }, + }); + + expect(result).toBeNull(); + }); + }); + + describe("Cache key user scoping", () => { + it("should generate different cache keys for different users with same query params", () => { + // This tests the buildDrawingsCacheKey function logic inline + // The function was updated to include userId in the cache key + const buildDrawingsCacheKey = (keyParts: { + userId: string; + searchTerm: string; + collectionFilter: string; + includeData: boolean; + }) => + JSON.stringify([ + keyParts.userId, + keyParts.searchTerm, + keyParts.collectionFilter, + keyParts.includeData ? "full" : "summary", + ]); + + const keyA = buildDrawingsCacheKey({ + userId: "user-a-id", + searchTerm: "", + collectionFilter: "default", + includeData: false, + }); + + const keyB = buildDrawingsCacheKey({ + userId: "user-b-id", + searchTerm: "", + collectionFilter: "default", + includeData: false, + }); + + expect(keyA).not.toBe(keyB); + }); + + it("should generate same cache key for same user with same query params", () => { + const buildDrawingsCacheKey = (keyParts: { + userId: string; + searchTerm: string; + collectionFilter: string; + includeData: boolean; + }) => + JSON.stringify([ + keyParts.userId, + keyParts.searchTerm, + keyParts.collectionFilter, + keyParts.includeData ? "full" : "summary", + ]); + + const key1 = buildDrawingsCacheKey({ + userId: "same-user", + searchTerm: "test", + collectionFilter: "default", + includeData: true, + }); + + const key2 = buildDrawingsCacheKey({ + userId: "same-user", + searchTerm: "test", + collectionFilter: "default", + includeData: true, + }); + + expect(key1).toBe(key2); + }); + }); +}); diff --git a/backend/src/auth.ts b/backend/src/auth.ts new file mode 100644 index 00000000..0ca7fded --- /dev/null +++ b/backend/src/auth.ts @@ -0,0 +1,460 @@ +import express, { Request, Response } from "express"; +import crypto from "crypto"; +import jwt, { SignOptions } from "jsonwebtoken"; +import ms, { type StringValue } from "ms"; +import { Prisma, PrismaClient } from "./generated/client"; +import { config } from "./config"; +import { + requireAuth as defaultRequireAuth, + optionalAuth as defaultOptionalAuth, + authModeService as defaultAuthModeService, +} from "./middleware/auth"; +import { getCsrfTokenHeader, sanitizeText, validateCsrfToken } from "./security"; +import rateLimit, { MemoryStore } from "express-rate-limit"; +import { registerAccountRoutes } from "./auth/accountRoutes"; +import { registerAdminRoutes } from "./auth/adminRoutes"; +import { registerCoreRoutes } from "./auth/coreRoutes"; +import { registerOidcRoutes } from "./auth/oidcRoutes"; +import { prisma as defaultPrisma } from "./db/prisma"; +import { + BOOTSTRAP_USER_ID, + DEFAULT_SYSTEM_CONFIG_ID, + type AuthModeService, +} from "./auth/authMode"; +import { getCsrfValidationClientIds } from "./security/csrfClient"; +import { + clearAuthCookies, + readCookie, + REFRESH_TOKEN_COOKIE_NAME, + setAccessTokenCookie, + setAuthCookies, +} from "./auth/cookies"; +import { getClientIp } from "./utils/clientIp"; + +interface JwtPayload { + userId: string; + email: string; + type: "access" | "refresh"; + impersonatorId?: string; +} + +const isJwtPayload = (decoded: unknown): decoded is JwtPayload => { + if (typeof decoded !== "object" || decoded === null) { + return false; + } + const payload = decoded as Record; + return ( + typeof payload.userId === "string" && + typeof payload.email === "string" && + (payload.type === "access" || payload.type === "refresh") + ); +}; + +type CreateAuthRouterDeps = { + prisma: PrismaClient; + requireAuth: express.RequestHandler; + optionalAuth: express.RequestHandler; + authModeService: AuthModeService; +}; + +export const createAuthRouter = (deps: CreateAuthRouterDeps): express.Router => { + const { prisma, requireAuth, optionalAuth, authModeService } = deps; + const router = express.Router(); + + const ensureSystemConfig = authModeService.ensureSystemConfig; + + const ensureAuthEnabled = async (res: Response): Promise => { + const systemConfig = await ensureSystemConfig(); + const authEnabled = + config.authMode !== "local" ? true : systemConfig.authEnabled; + if (!authEnabled) { + res.status(404).json({ + error: "Not found", + message: "Authentication is disabled", + }); + return false; + } + return true; + }; + + type LoginRateLimitConfig = { + enabled: boolean; + windowMs: number; + max: number; + }; + + const DEFAULT_LOGIN_RATE_LIMIT: LoginRateLimitConfig = { + enabled: true, + windowMs: 15 * 60 * 1000, + max: 20, + }; + + let loginRateLimitConfig: LoginRateLimitConfig = { ...DEFAULT_LOGIN_RATE_LIMIT }; + let loginAttemptLimiter: ReturnType | null = null; + let loginLimiterInitPromise: Promise | null = null; + let loginIdentifierKeyIndex = new Map>(); + + const parseLoginRateLimitConfig = ( + systemConfig: Awaited> + ): LoginRateLimitConfig => { + const enabled = + typeof systemConfig.authLoginRateLimitEnabled === "boolean" + ? systemConfig.authLoginRateLimitEnabled + : DEFAULT_LOGIN_RATE_LIMIT.enabled; + const windowMs = + Number.isFinite(Number(systemConfig.authLoginRateLimitWindowMs)) && + Number(systemConfig.authLoginRateLimitWindowMs) > 0 + ? Number(systemConfig.authLoginRateLimitWindowMs) + : DEFAULT_LOGIN_RATE_LIMIT.windowMs; + const max = + Number.isFinite(Number(systemConfig.authLoginRateLimitMax)) && + Number(systemConfig.authLoginRateLimitMax) > 0 + ? Number(systemConfig.authLoginRateLimitMax) + : DEFAULT_LOGIN_RATE_LIMIT.max; + return { enabled, windowMs, max }; + }; + + const resolveAuthIdentifier = (req: Request): string | null => { + const body = (req.body || {}) as Record; + const raw = + (typeof body.email === "string" && body.email) || + (typeof body.username === "string" && body.username) || + (typeof body.identifier === "string" && body.identifier) || + null; + if (!raw) return null; + const trimmed = raw.trim().toLowerCase(); + return trimmed.length > 0 ? trimmed.slice(0, 255) : null; + }; + + const resolveRateLimitIp = (req: Request): string => getClientIp(req); + + const trackIdentifierRateLimitKey = (identifier: string, key: string): void => { + if (!loginIdentifierKeyIndex.has(identifier) && loginIdentifierKeyIndex.size >= 5000) { + const oldestIdentifier = loginIdentifierKeyIndex.keys().next().value; + if (typeof oldestIdentifier === "string") { + loginIdentifierKeyIndex.delete(oldestIdentifier); + } + } + + const existing = loginIdentifierKeyIndex.get(identifier) ?? new Set(); + if (existing.size >= 50) { + const oldestKey = existing.values().next().value; + if (typeof oldestKey === "string") { + existing.delete(oldestKey); + } + } + existing.add(key); + loginIdentifierKeyIndex.set(identifier, existing); + }; + + const buildLoginAttemptLimiter = (cfg: LoginRateLimitConfig) => { + const store = new MemoryStore(); + loginIdentifierKeyIndex = new Map>(); + const limiter = rateLimit({ + windowMs: cfg.windowMs, + max: cfg.max, + message: { + error: "Too many requests", + message: "Too many login attempts, please try again later", + }, + standardHeaders: true, + legacyHeaders: false, + validate: { + trustProxy: false, + }, + store, + keyGenerator: (req) => { + const identifier = resolveAuthIdentifier(req as Request); + const ip = resolveRateLimitIp(req as Request); + if (identifier) { + const key = `login:${identifier}:ip:${ip}`; + trackIdentifierRateLimitKey(identifier, key); + return key; + } + return `login-ip:${ip}`; + }, + }); + + loginAttemptLimiter = limiter; + }; + + const initLoginAttemptLimiter = async () => { + const systemConfig = await ensureSystemConfig(); + loginRateLimitConfig = parseLoginRateLimitConfig(systemConfig); + buildLoginAttemptLimiter(loginRateLimitConfig); + }; + + const ensureLoginAttemptLimiter = async () => { + if (loginAttemptLimiter) return; + if (!loginLimiterInitPromise) { + loginLimiterInitPromise = initLoginAttemptLimiter().finally(() => { + loginLimiterInitPromise = null; + }); + } + await loginLimiterInitPromise; + }; + + const applyLoginRateLimitConfig = ( + systemConfig: Pick< + Awaited>, + "authLoginRateLimitEnabled" | "authLoginRateLimitWindowMs" | "authLoginRateLimitMax" + > + ): LoginRateLimitConfig => { + loginRateLimitConfig = parseLoginRateLimitConfig( + systemConfig as Awaited> + ); + buildLoginAttemptLimiter(loginRateLimitConfig); + return loginRateLimitConfig; + }; + + const resetLoginAttemptKey = async (identifier: string): Promise => { + await ensureLoginAttemptLimiter(); + const normalizedIdentifier = identifier.trim().toLowerCase(); + const keys = loginIdentifierKeyIndex.get(normalizedIdentifier); + try { + if (!keys || keys.size === 0) { + // Backward-compatible fallback for pre-change key format. + await loginAttemptLimiter?.resetKey(`login:${normalizedIdentifier}`); + return; + } + for (const key of keys) { + await loginAttemptLimiter?.resetKey(key); + } + loginIdentifierKeyIndex.delete(normalizedIdentifier); + } catch (error) { + if (process.env.NODE_ENV === "development") { + console.debug("Rate limit reset skipped:", error); + } + } + }; + + const loginAttemptRateLimiter = async ( + req: Request, + res: Response, + next: express.NextFunction + ) => { + await ensureLoginAttemptLimiter(); + if (!loginRateLimitConfig.enabled) return next(); + return (loginAttemptLimiter as ReturnType)(req, res, next); + }; + + const accountActionRateLimiter = rateLimit({ + windowMs: 5 * 60 * 1000, + max: 60, + message: { + error: "Too many requests", + message: "Too many requests, please try again later", + }, + standardHeaders: true, + legacyHeaders: false, + validate: { + trustProxy: false, + }, + keyGenerator: (req) => getClientIp(req as Request), + }); + + const generateTempPassword = (): string => { + const buf = crypto.randomBytes(18); + return buf.toString("base64").replace(/[+/=]/g, "").slice(0, 24); + }; + + const findUserByIdentifier = async (identifier: string) => { + const trimmed = identifier.trim(); + if (trimmed.length === 0) return null; + + const looksLikeEmail = trimmed.includes("@"); + if (looksLikeEmail) { + return prisma.user.findUnique({ + where: { email: trimmed.toLowerCase() }, + }); + } + + return prisma.user.findFirst({ + where: { + OR: [{ username: trimmed }, { email: trimmed.toLowerCase() }], + }, + }); + }; + + const requireAdmin = ( + req: Request, + res: Response + ): req is Request & { user: NonNullable } => { + if (!req.user) { + res.status(401).json({ error: "Unauthorized", message: "User not authenticated" }); + return false; + } + if (req.user.role !== "ADMIN") { + res.status(403).json({ error: "Forbidden", message: "Admin access required" }); + return false; + } + return true; + }; + + const requireCsrf = (req: Request, res: Response): boolean => { + const headerName = getCsrfTokenHeader(); + const tokenHeader = req.headers[headerName]; + const token = Array.isArray(tokenHeader) ? tokenHeader[0] : tokenHeader; + + if (!token) { + res.status(403).json({ + error: "CSRF token missing", + message: `Missing ${headerName} header`, + }); + return false; + } + + const clientIds = getCsrfValidationClientIds(req); + const isValidToken = clientIds.some((clientId) => validateCsrfToken(clientId, token)); + if (!isValidToken) { + res.status(403).json({ + error: "CSRF token invalid", + message: "Invalid or expired CSRF token. Please refresh and try again.", + }); + return false; + } + + return true; + }; + + const countActiveAdmins = async () => { + return prisma.user.count({ + where: { role: "ADMIN", isActive: true }, + }); + }; + + const generateTokens = ( + userId: string, + email: string, + options?: { impersonatorId?: string } + ) => { + const signOptions: SignOptions = { + expiresIn: config.jwtAccessExpiresIn as StringValue, + }; + const accessToken = jwt.sign( + { userId, email, type: "access", impersonatorId: options?.impersonatorId }, + config.jwtSecret, + signOptions + ); + + const refreshSignOptions: SignOptions = { + expiresIn: config.jwtRefreshExpiresIn as StringValue, + }; + const refreshToken = jwt.sign( + { userId, email, type: "refresh", impersonatorId: options?.impersonatorId }, + config.jwtSecret, + refreshSignOptions + ); + + return { accessToken, refreshToken }; + }; + + const resolveExpiresAt = (expiresIn: string, fallbackMs: number): Date => { + const parsed = ms(expiresIn as StringValue); + const ttlMs = typeof parsed === "number" && parsed > 0 ? parsed : fallbackMs; + return new Date(Date.now() + ttlMs); + }; + + const isMissingRefreshTokenTableError = (error: unknown): boolean => { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (error.code === "P2021") { + return true; + } + } + + const message = + typeof error === "object" && error && "message" in error + ? String((error as any).message) + : ""; + return /no such table:\s*RefreshToken/i.test(message); + }; + + const getRefreshTokenExpiresAt = (): Date => + resolveExpiresAt(config.jwtRefreshExpiresIn, 7 * 24 * 60 * 60 * 1000); + + registerOidcRoutes({ + router, + prisma, + ensureAuthEnabled, + sanitizeText, + generateTokens, + setAuthCookies, + getRefreshTokenExpiresAt, + isMissingRefreshTokenTableError, + config, + }); + + registerCoreRoutes({ + router, + prisma, + requireAuth, + optionalAuth, + loginAttemptRateLimiter, + ensureAuthEnabled, + ensureSystemConfig, + findUserByIdentifier, + sanitizeText, + requireCsrf, + isJwtPayload, + config, + generateTokens, + getRefreshTokenExpiresAt, + isMissingRefreshTokenTableError, + bootstrapUserId: BOOTSTRAP_USER_ID, + defaultSystemConfigId: DEFAULT_SYSTEM_CONFIG_ID, + clearAuthEnabledCache: authModeService.clearAuthEnabledCache, + setAuthCookies, + setAccessTokenCookie, + clearAuthCookies, + readRefreshTokenFromRequest: (req) => readCookie(req, REFRESH_TOKEN_COOKIE_NAME), + }); + + registerAdminRoutes({ + router, + prisma, + requireAuth, + accountActionRateLimiter, + ensureAuthEnabled, + ensureSystemConfig, + parseLoginRateLimitConfig, + applyLoginRateLimitConfig, + resetLoginAttemptKey, + requireAdmin, + findUserByIdentifier, + countActiveAdmins, + sanitizeText, + generateTempPassword, + generateTokens, + getRefreshTokenExpiresAt, + config, + defaultSystemConfigId: DEFAULT_SYSTEM_CONFIG_ID, + setAuthCookies, + requireCsrf, + }); + + registerAccountRoutes({ + router, + prisma, + requireAuth, + loginAttemptRateLimiter, + accountActionRateLimiter, + ensureAuthEnabled, + sanitizeText, + config, + generateTokens, + getRefreshTokenExpiresAt, + setAuthCookies, + requireCsrf, + }); + + return router; +}; + +const authRouter = createAuthRouter({ + prisma: defaultPrisma, + requireAuth: defaultRequireAuth, + optionalAuth: defaultOptionalAuth, + authModeService: defaultAuthModeService, +}); + +export default authRouter; diff --git a/backend/src/auth/accountRoutes.ts b/backend/src/auth/accountRoutes.ts new file mode 100644 index 00000000..fcc7c585 --- /dev/null +++ b/backend/src/auth/accountRoutes.ts @@ -0,0 +1,580 @@ +import express, { Request, Response } from "express"; +import bcrypt from "bcrypt"; +import crypto from "crypto"; +import { PrismaClient } from "../generated/client"; +import { logAuditEvent } from "../utils/audit"; +import { + changePasswordSchema, + mustResetPasswordSchema, + passwordResetConfirmSchema, + passwordResetRequestSchema, + updateEmailSchema, + updateProfileSchema, +} from "./schemas"; +import { getTokenLookupCandidates, hashTokenForStorage } from "./tokenSecurity"; + +type RegisterAccountRoutesDeps = { + router: express.Router; + prisma: PrismaClient; + requireAuth: express.RequestHandler; + loginAttemptRateLimiter: express.RequestHandler; + accountActionRateLimiter: express.RequestHandler; + ensureAuthEnabled: (res: Response) => Promise; + sanitizeText: (input: unknown, maxLength?: number) => string; + config: { + enablePasswordReset: boolean; + enableAuditLogging: boolean; + enableRefreshTokenRotation: boolean; + nodeEnv: string; + frontendUrl?: string; + }; + generateTokens: ( + userId: string, + email: string, + options?: { impersonatorId?: string } + ) => { accessToken: string; refreshToken: string }; + getRefreshTokenExpiresAt: () => Date; + setAuthCookies: ( + req: Request, + res: Response, + tokens: { accessToken: string; refreshToken: string } + ) => void; + requireCsrf: (req: Request, res: Response) => boolean; +}; + +export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => { + const { + router, + prisma, + requireAuth, + loginAttemptRateLimiter, + accountActionRateLimiter, + ensureAuthEnabled, + sanitizeText, + config, + generateTokens, + getRefreshTokenExpiresAt, + setAuthCookies, + requireCsrf, + } = deps; + + router.post("/password-reset-request", loginAttemptRateLimiter, async (req: Request, res: Response) => { + if (!(await ensureAuthEnabled(res))) return; + if (!config.enablePasswordReset) { + return res.status(404).json({ + error: "Not found", + message: "Password reset feature is not enabled", + }); + } + + try { + const parsed = passwordResetRequestSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ + error: "Validation error", + message: "Invalid email address", + }); + } + + const { email } = parsed.data; + const user = await prisma.user.findUnique({ where: { email } }); + + if (user && user.isActive) { + const resetToken = crypto.randomBytes(32).toString("hex"); + const expiresAt = new Date(); + expiresAt.setHours(expiresAt.getHours() + 1); + + await prisma.passwordResetToken.updateMany({ + where: { userId: user.id, used: false }, + data: { used: true }, + }); + + await prisma.passwordResetToken.create({ + data: { userId: user.id, token: hashTokenForStorage(resetToken), expiresAt }, + }); + + if (config.enableAuditLogging) { + await logAuditEvent({ + userId: user.id, + action: "password_reset_requested", + ipAddress: req.ip || req.connection.remoteAddress || undefined, + userAgent: req.headers["user-agent"] || undefined, + }); + } + + if (config.nodeEnv === "development") { + console.log(`[DEV] Password reset token for ${email}: ${resetToken}`); + const baseUrlRaw = config.frontendUrl?.split(",")[0]?.trim(); + const baseUrlWithProtocol = baseUrlRaw + ? /^https?:\/\//i.test(baseUrlRaw) + ? baseUrlRaw + : `http://${baseUrlRaw}` + : "http://localhost:6767"; + const baseUrl = baseUrlWithProtocol.replace(/\/$/, ""); + console.log(`[DEV] Reset URL: ${baseUrl}/reset-password-confirm?token=${resetToken}`); + } + } + + return res.json({ + message: "If an account with that email exists, a password reset link has been sent.", + }); + } catch (error) { + console.error("Password reset request error:", error); + return res.status(500).json({ + error: "Internal server error", + message: "Failed to process password reset request", + }); + } + }); + + router.post("/password-reset-confirm", loginAttemptRateLimiter, async (req: Request, res: Response) => { + if (!(await ensureAuthEnabled(res))) return; + if (!config.enablePasswordReset) { + return res.status(404).json({ + error: "Not found", + message: "Password reset feature is not enabled", + }); + } + + try { + const parsed = passwordResetConfirmSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ + error: "Validation error", + message: "Invalid reset data", + }); + } + + const { token, password } = parsed.data; + const resetToken = await prisma.passwordResetToken.findFirst({ + where: { + OR: getTokenLookupCandidates(token).map((candidate) => ({ token: candidate })), + }, + include: { user: true }, + }); + + if (!resetToken || resetToken.used) { + return res.status(400).json({ + error: "Invalid token", + message: "Password reset token is invalid or has already been used", + }); + } + if (new Date() > resetToken.expiresAt) { + return res.status(400).json({ + error: "Expired token", + message: "Password reset token has expired", + }); + } + if (!resetToken.user.isActive) { + return res.status(403).json({ + error: "Forbidden", + message: "Account is inactive", + }); + } + + const saltRounds = 10; + const passwordHash = await bcrypt.hash(password, saltRounds); + await prisma.user.update({ + where: { id: resetToken.userId }, + data: { passwordHash, mustResetPassword: false }, + }); + await prisma.passwordResetToken.update({ + where: { id: resetToken.id }, + data: { used: true }, + }); + + if (config.enableRefreshTokenRotation) { + try { + await prisma.refreshToken.updateMany({ + where: { userId: resetToken.userId, revoked: false }, + data: { revoked: true }, + }); + } catch { + if (process.env.NODE_ENV === "development") { + console.debug("Refresh token revocation skipped (feature disabled or table missing)"); + } + } + } + + if (config.enableAuditLogging) { + await logAuditEvent({ + userId: resetToken.userId, + action: "password_changed", + ipAddress: req.ip || req.connection.remoteAddress || undefined, + userAgent: req.headers["user-agent"] || undefined, + }); + } + + return res.json({ message: "Password has been reset successfully" }); + } catch (error) { + console.error("Password reset confirm error:", error); + return res.status(500).json({ + error: "Internal server error", + message: "Failed to reset password", + }); + } + }); + + router.put("/profile", requireAuth, async (req: Request, res: Response) => { + try { + if (!(await ensureAuthEnabled(res))) return; + if (!requireCsrf(req, res)) return; + if (!req.user) { + return res.status(401).json({ + error: "Unauthorized", + message: "User not authenticated", + }); + } + if (req.user.impersonatorId) { + return res.status(403).json({ + error: "Forbidden", + message: "Profile updates are not allowed while impersonating", + }); + } + + const parsed = updateProfileSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ + error: "Validation error", + message: "Invalid name format", + }); + } + + const sanitizedName = sanitizeText(parsed.data.name, 100); + const updatedUser = await prisma.user.update({ + where: { id: req.user.id }, + data: { name: sanitizedName }, + select: { id: true, email: true, name: true, createdAt: true, updatedAt: true }, + }); + + if (config.enableAuditLogging) { + await logAuditEvent({ + userId: req.user.id, + action: "profile_updated", + ipAddress: req.ip || req.connection.remoteAddress || undefined, + userAgent: req.headers["user-agent"] || undefined, + details: { field: "name" }, + }); + } + + return res.json({ user: updatedUser }); + } catch (error) { + console.error("Update profile error:", error); + return res.status(500).json({ + error: "Internal server error", + message: "Failed to update profile", + }); + } + }); + + router.put("/email", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => { + try { + if (!(await ensureAuthEnabled(res))) return; + if (!requireCsrf(req, res)) return; + if (!req.user) { + return res.status(401).json({ error: "Unauthorized", message: "User not authenticated" }); + } + if (req.user.impersonatorId) { + return res.status(403).json({ + error: "Forbidden", + message: "Email changes are not allowed while impersonating", + }); + } + + const parsed = updateEmailSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ + error: "Validation error", + message: "Invalid email update data", + }); + } + + const user = await prisma.user.findUnique({ + where: { id: req.user.id }, + select: { id: true, email: true, passwordHash: true, isActive: true }, + }); + if (!user || !user.isActive) { + return res.status(401).json({ + error: "Unauthorized", + message: "User account not found or inactive", + }); + } + if (!user.passwordHash) { + return res.status(400).json({ + error: "Bad request", + message: "Cannot change email for this account", + }); + } + + const passwordValid = await bcrypt.compare(parsed.data.currentPassword, user.passwordHash); + if (!passwordValid) { + return res.status(401).json({ + error: "Unauthorized", + message: "Current password is incorrect", + }); + } + + if (parsed.data.email !== user.email) { + const existingUser = await prisma.user.findUnique({ + where: { email: parsed.data.email }, + select: { id: true }, + }); + if (existingUser && existingUser.id !== user.id) { + return res.status(409).json({ + error: "Conflict", + message: "User with this email already exists", + }); + } + } + + const previousEmail = user.email; + const updatedUser = await prisma.user.update({ + where: { id: user.id }, + data: { email: parsed.data.email }, + select: { + id: true, + username: true, + email: true, + name: true, + role: true, + mustResetPassword: true, + createdAt: true, + updatedAt: true, + }, + }); + + if (config.enableRefreshTokenRotation) { + try { + await prisma.refreshToken.updateMany({ + where: { userId: updatedUser.id, revoked: false }, + data: { revoked: true }, + }); + } catch { + if (process.env.NODE_ENV === "development") { + console.debug("Refresh token revocation skipped (feature disabled or table missing)"); + } + } + } + + const { accessToken, refreshToken } = generateTokens(updatedUser.id, updatedUser.email); + setAuthCookies(req, res, { accessToken, refreshToken }); + if (config.enableRefreshTokenRotation) { + const expiresAt = getRefreshTokenExpiresAt(); + try { + await prisma.refreshToken.create({ + data: { + userId: updatedUser.id, + token: hashTokenForStorage(refreshToken), + expiresAt, + }, + }); + } catch { + if (process.env.NODE_ENV === "development") { + console.debug("Refresh token storage skipped (feature disabled or table missing)"); + } + } + } + + if (config.enableAuditLogging) { + await logAuditEvent({ + userId: updatedUser.id, + action: "email_updated", + ipAddress: req.ip || req.connection.remoteAddress || undefined, + userAgent: req.headers["user-agent"] || undefined, + details: { previousEmail, newEmail: updatedUser.email }, + }); + } + + return res.json({ user: updatedUser, accessToken, refreshToken }); + } catch (error) { + console.error("Update email error:", error); + return res.status(500).json({ + error: "Internal server error", + message: "Failed to update email", + }); + } + }); + + router.post("/change-password", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => { + try { + if (!(await ensureAuthEnabled(res))) return; + if (!requireCsrf(req, res)) return; + if (!req.user) { + return res.status(401).json({ error: "Unauthorized", message: "User not authenticated" }); + } + if (req.user.impersonatorId) { + return res.status(403).json({ + error: "Forbidden", + message: "Password changes are not allowed while impersonating", + }); + } + + const parsed = changePasswordSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ + error: "Validation error", + message: "Invalid password data", + }); + } + + const user = await prisma.user.findUnique({ + where: { id: req.user.id }, + select: { id: true, passwordHash: true, isActive: true }, + }); + if (!user || !user.isActive) { + return res.status(404).json({ error: "Not found", message: "User not found" }); + } + + const passwordValid = await bcrypt.compare(parsed.data.currentPassword, user.passwordHash); + if (!passwordValid) { + return res.status(401).json({ + error: "Unauthorized", + message: "Current password is incorrect", + }); + } + + const passwordHash = await bcrypt.hash(parsed.data.newPassword, 10); + await prisma.user.update({ + where: { id: user.id }, + data: { passwordHash, mustResetPassword: false }, + }); + + if (config.enableRefreshTokenRotation) { + try { + await prisma.refreshToken.updateMany({ + where: { userId: user.id, revoked: false }, + data: { revoked: true }, + }); + } catch { + if (process.env.NODE_ENV === "development") { + console.debug("Refresh token revocation skipped (feature disabled or table missing)"); + } + } + } + + if (config.enableAuditLogging) { + await logAuditEvent({ + userId: user.id, + action: "password_changed", + ipAddress: req.ip || req.connection.remoteAddress || undefined, + userAgent: req.headers["user-agent"] || undefined, + details: { method: "change_password" }, + }); + } + + return res.json({ message: "Password changed successfully" }); + } catch (error) { + console.error("Change password error:", error); + return res.status(500).json({ + error: "Internal server error", + message: "Failed to change password", + }); + } + }); + + router.post("/must-reset-password", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => { + try { + if (!(await ensureAuthEnabled(res))) return; + if (!requireCsrf(req, res)) return; + if (!req.user) { + return res.status(401).json({ error: "Unauthorized", message: "User not authenticated" }); + } + if (req.user.impersonatorId) { + return res.status(403).json({ + error: "Forbidden", + message: "Password changes are not allowed while impersonating", + }); + } + + const parsed = mustResetPasswordSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ + error: "Validation error", + message: "Invalid password data", + }); + } + + const user = await prisma.user.findUnique({ + where: { id: req.user.id }, + select: { id: true, email: true, isActive: true, mustResetPassword: true }, + }); + if (!user || !user.isActive) { + return res.status(401).json({ + error: "Unauthorized", + message: "User account not found or inactive", + }); + } + if (!user.mustResetPassword) { + return res.status(409).json({ + error: "Conflict", + message: "Password reset is not required for this account", + }); + } + + const passwordHash = await bcrypt.hash(parsed.data.newPassword, 10); + const updatedUser = await prisma.user.update({ + where: { id: user.id }, + data: { passwordHash, mustResetPassword: false }, + select: { + id: true, + username: true, + email: true, + name: true, + role: true, + mustResetPassword: true, + createdAt: true, + updatedAt: true, + }, + }); + + if (config.enableRefreshTokenRotation) { + try { + await prisma.refreshToken.updateMany({ + where: { userId: updatedUser.id, revoked: false }, + data: { revoked: true }, + }); + } catch { + if (process.env.NODE_ENV === "development") { + console.debug("Refresh token revocation skipped (feature disabled or table missing)"); + } + } + } + + const { accessToken, refreshToken } = generateTokens(updatedUser.id, updatedUser.email); + setAuthCookies(req, res, { accessToken, refreshToken }); + if (config.enableRefreshTokenRotation) { + const expiresAt = getRefreshTokenExpiresAt(); + try { + await prisma.refreshToken.create({ + data: { + userId: updatedUser.id, + token: hashTokenForStorage(refreshToken), + expiresAt, + }, + }); + } catch { + if (process.env.NODE_ENV === "development") { + console.debug("Refresh token storage skipped (feature disabled or table missing)"); + } + } + } + + if (config.enableAuditLogging) { + await logAuditEvent({ + userId: updatedUser.id, + action: "password_reset_required_completed", + ipAddress: req.ip || req.connection.remoteAddress || undefined, + userAgent: req.headers["user-agent"] || undefined, + }); + } + + return res.json({ user: updatedUser, accessToken, refreshToken }); + } catch (error) { + console.error("Must reset password error:", error); + return res.status(500).json({ + error: "Internal server error", + message: "Failed to reset password", + }); + } + }); +}; diff --git a/backend/src/auth/adminRoutes.ts b/backend/src/auth/adminRoutes.ts new file mode 100644 index 00000000..89a6dd45 --- /dev/null +++ b/backend/src/auth/adminRoutes.ts @@ -0,0 +1,767 @@ +import bcrypt from "bcrypt"; +import express, { Request, Response } from "express"; +import { Prisma, PrismaClient } from "../generated/client"; +import { logAuditEvent } from "../utils/audit"; +import { + adminCreateUserSchema, + adminRoleUpdateSchema, + adminUpdateUserSchema, + impersonateSchema, + loginRateLimitResetSchema, + loginRateLimitUpdateSchema, + registrationToggleSchema, +} from "./schemas"; +import { hashTokenForStorage } from "./tokenSecurity"; + +type RegisterAdminRoutesDeps = { + router: express.Router; + prisma: PrismaClient; + requireAuth: express.RequestHandler; + accountActionRateLimiter: express.RequestHandler; + ensureAuthEnabled: (res: Response) => Promise; + ensureSystemConfig: () => Promise<{ + id: string; + authLoginRateLimitEnabled: boolean; + authLoginRateLimitWindowMs: number; + authLoginRateLimitMax: number; + }>; + parseLoginRateLimitConfig: (systemConfig: { + authLoginRateLimitEnabled: boolean; + authLoginRateLimitWindowMs: number; + authLoginRateLimitMax: number; + }) => { enabled: boolean; windowMs: number; max: number }; + applyLoginRateLimitConfig: (systemConfig: { + authLoginRateLimitEnabled: boolean; + authLoginRateLimitWindowMs: number; + authLoginRateLimitMax: number; + }) => { enabled: boolean; windowMs: number; max: number }; + resetLoginAttemptKey: (identifier: string) => Promise; + requireAdmin: ( + req: Request, + res: Response + ) => req is Request & { user: NonNullable }; + findUserByIdentifier: (identifier: string) => Promise<{ + id: string; + username: string | null; + email: string; + name: string; + role: string; + isActive: boolean; + mustResetPassword: boolean; + passwordHash: string; + } | null>; + countActiveAdmins: () => Promise; + sanitizeText: (input: unknown, maxLength?: number) => string; + generateTempPassword: () => string; + generateTokens: ( + userId: string, + email: string, + options?: { impersonatorId?: string } + ) => { accessToken: string; refreshToken: string }; + getRefreshTokenExpiresAt: () => Date; + config: { + enableAuditLogging: boolean; + enableRefreshTokenRotation: boolean; + }; + defaultSystemConfigId: string; + setAuthCookies: ( + req: Request, + res: Response, + tokens: { accessToken: string; refreshToken: string } + ) => void; + requireCsrf: (req: Request, res: Response) => boolean; +}; + +export const registerAdminRoutes = (deps: RegisterAdminRoutesDeps) => { + const { + router, + prisma, + requireAuth, + accountActionRateLimiter, + ensureAuthEnabled, + ensureSystemConfig, + parseLoginRateLimitConfig, + applyLoginRateLimitConfig, + resetLoginAttemptKey, + requireAdmin, + findUserByIdentifier, + countActiveAdmins, + sanitizeText, + generateTempPassword, + generateTokens, + getRefreshTokenExpiresAt, + config, + defaultSystemConfigId, + setAuthCookies, + requireCsrf, + } = deps; + + const resolveImpersonationAdmin = async (req: Request, res: Response) => { + if (!req.user) { + res.status(401).json({ error: "Unauthorized", message: "User not authenticated" }); + return null; + } + + if (req.user.role === "ADMIN") { + return { + id: req.user.id, + email: req.user.email, + name: req.user.name, + }; + } + + if (!req.user.impersonatorId) { + res.status(403).json({ error: "Forbidden", message: "Admin access required" }); + return null; + } + + const impersonator = await prisma.user.findUnique({ + where: { id: req.user.impersonatorId }, + select: { + id: true, + email: true, + name: true, + role: true, + isActive: true, + }, + }); + + if (!impersonator || !impersonator.isActive || impersonator.role !== "ADMIN") { + res.status(403).json({ error: "Forbidden", message: "Admin access required" }); + return null; + } + + return { + id: impersonator.id, + email: impersonator.email, + name: impersonator.name, + }; + }; + + router.post("/registration/toggle", requireAuth, async (req: Request, res: Response) => { + try { + if (!(await ensureAuthEnabled(res))) return; + if (!requireCsrf(req, res)) return; + if (!requireAdmin(req, res)) return; + + const parsed = registrationToggleSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: "Bad request", message: "Invalid toggle payload" }); + } + + const updated = await prisma.systemConfig.upsert({ + where: { id: defaultSystemConfigId }, + update: { registrationEnabled: parsed.data.enabled }, + create: { id: defaultSystemConfigId, registrationEnabled: parsed.data.enabled }, + }); + + res.json({ registrationEnabled: updated.registrationEnabled }); + } catch (error) { + console.error("Registration toggle error:", error); + res.status(500).json({ + error: "Internal server error", + message: "Failed to update registration setting", + }); + } + }); + + router.post("/admins", requireAuth, async (req: Request, res: Response) => { + try { + if (!(await ensureAuthEnabled(res))) return; + if (!requireCsrf(req, res)) return; + if (!requireAdmin(req, res)) return; + + const parsed = adminRoleUpdateSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: "Bad request", message: "Invalid admin update payload" }); + } + + const target = await findUserByIdentifier(parsed.data.identifier); + if (!target) { + return res.status(404).json({ error: "Not found", message: "User not found" }); + } + + if (target.id === req.user.id && parsed.data.role !== "ADMIN") { + return res.status(409).json({ + error: "Conflict", + message: "You cannot change your own role from ADMIN", + }); + } + + if (target.role === "ADMIN" && parsed.data.role !== "ADMIN" && target.isActive) { + const admins = await countActiveAdmins(); + if (admins <= 1) { + return res.status(409).json({ + error: "Conflict", + message: "There must be at least one active admin", + }); + } + } + + const updated = await prisma.user.update({ + where: { id: target.id }, + data: { role: parsed.data.role }, + select: { + id: true, + username: true, + email: true, + name: true, + role: true, + mustResetPassword: true, + isActive: true, + }, + }); + + res.json({ user: updated }); + } catch (error) { + console.error("Admin role update error:", error); + res.status(500).json({ + error: "Internal server error", + message: "Failed to update user role", + }); + } + }); + + router.get("/users", requireAuth, async (req: Request, res: Response) => { + try { + if (!(await ensureAuthEnabled(res))) return; + if (!requireAdmin(req, res)) return; + + const users = await prisma.user.findMany({ + orderBy: [{ createdAt: "asc" }], + select: { + id: true, + username: true, + email: true, + name: true, + role: true, + mustResetPassword: true, + isActive: true, + createdAt: true, + updatedAt: true, + }, + }); + + res.json({ users }); + } catch (error) { + console.error("List users error:", error); + res.status(500).json({ + error: "Internal server error", + message: "Failed to list users", + }); + } + }); + + router.get("/impersonation-targets", requireAuth, async (req: Request, res: Response) => { + try { + if (!(await ensureAuthEnabled(res))) return; + const actingAdmin = await resolveImpersonationAdmin(req, res); + if (!actingAdmin) return; + + const users = await prisma.user.findMany({ + where: { isActive: true, id: { not: actingAdmin.id } }, + orderBy: [{ name: "asc" }, { email: "asc" }], + select: { + id: true, + username: true, + email: true, + name: true, + role: true, + isActive: true, + }, + }); + + res.json({ + users, + impersonator: { + id: actingAdmin.id, + email: actingAdmin.email, + name: actingAdmin.name, + }, + }); + } catch (error) { + console.error("List impersonation targets error:", error); + res.status(500).json({ + error: "Internal server error", + message: "Failed to list impersonation targets", + }); + } + }); + + router.get("/rate-limit/login", requireAuth, async (req: Request, res: Response) => { + try { + if (!(await ensureAuthEnabled(res))) return; + if (!requireAdmin(req, res)) return; + + const systemConfig = await ensureSystemConfig(); + const cfg = parseLoginRateLimitConfig(systemConfig); + res.json({ config: cfg }); + } catch (error) { + console.error("Get login rate limit config error:", error); + res.status(500).json({ + error: "Internal server error", + message: "Failed to fetch login rate limit config", + }); + } + }); + + router.put("/rate-limit/login", requireAuth, async (req: Request, res: Response) => { + try { + if (!(await ensureAuthEnabled(res))) return; + if (!requireCsrf(req, res)) return; + if (!requireAdmin(req, res)) return; + + const parsed = loginRateLimitUpdateSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ + error: "Validation error", + message: "Invalid rate limit config", + }); + } + + const updated = await prisma.systemConfig.update({ + where: { id: defaultSystemConfigId }, + data: { + authLoginRateLimitEnabled: parsed.data.enabled, + authLoginRateLimitWindowMs: parsed.data.windowMs, + authLoginRateLimitMax: parsed.data.max, + }, + }); + + const nextConfig = applyLoginRateLimitConfig(updated); + + if (config.enableAuditLogging) { + await logAuditEvent({ + userId: req.user.id, + action: "admin_login_rate_limit_updated", + resource: "system_config", + ipAddress: req.ip || req.connection.remoteAddress || undefined, + userAgent: req.headers["user-agent"] || undefined, + details: { ...nextConfig }, + }); + } + + res.json({ config: nextConfig }); + } catch (error) { + console.error("Update login rate limit config error:", error); + res.status(500).json({ + error: "Internal server error", + message: "Failed to update login rate limit config", + }); + } + }); + + router.post("/rate-limit/login/reset", requireAuth, async (req: Request, res: Response) => { + try { + if (!(await ensureAuthEnabled(res))) return; + if (!requireCsrf(req, res)) return; + if (!requireAdmin(req, res)) return; + + const parsed = loginRateLimitResetSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ + error: "Validation error", + message: "Invalid reset payload", + }); + } + + const identifier = parsed.data.identifier.trim().toLowerCase(); + await resetLoginAttemptKey(identifier); + + if (config.enableAuditLogging) { + await logAuditEvent({ + userId: req.user.id, + action: "admin_login_rate_limit_reset", + resource: `rate_limit:login:${identifier}`, + ipAddress: req.ip || req.connection.remoteAddress || undefined, + userAgent: req.headers["user-agent"] || undefined, + details: { identifier }, + }); + } + + res.json({ ok: true }); + } catch (error) { + console.error("Reset login rate limit error:", error); + res.status(500).json({ + error: "Internal server error", + message: "Failed to reset login rate limit", + }); + } + }); + + router.post("/users", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => { + try { + if (!(await ensureAuthEnabled(res))) return; + if (!requireCsrf(req, res)) return; + if (!requireAdmin(req, res)) return; + + const parsed = adminCreateUserSchema.safeParse(req.body); + if (!parsed.success) { + const summarizedIssues = parsed.error.issues.map((issue) => ({ + code: issue.code, + path: issue.path.join("."), + message: issue.message, + })); + console.warn("[auth/users] validation failed", { + issues: summarizedIssues, + requestId: req.headers["x-request-id"], + ip: req.ip || req.connection.remoteAddress || "unknown", + }); + return res.status(400).json({ + error: "Validation error", + message: "Invalid user payload", + }); + } + + const { email, password, name, username, role, mustResetPassword, isActive } = parsed.data; + + const existingUser = await prisma.user.findUnique({ where: { email } }); + if (existingUser) { + return res.status(409).json({ + error: "Conflict", + message: "User with this email already exists", + }); + } + + if (username) { + const existingUsername = await prisma.user.findFirst({ + where: { username }, + select: { id: true }, + }); + if (existingUsername) { + return res.status(409).json({ + error: "Conflict", + message: "User with this username already exists", + }); + } + } + + const saltRounds = 10; + const passwordHash = await bcrypt.hash(password, saltRounds); + const sanitizedName = sanitizeText(name, 100); + + const user = await prisma.user.create({ + data: { + email, + username: username ?? null, + passwordHash, + name: sanitizedName, + role: role ?? "USER", + mustResetPassword: mustResetPassword ?? false, + isActive: isActive ?? true, + }, + select: { + id: true, + username: true, + email: true, + name: true, + role: true, + mustResetPassword: true, + isActive: true, + createdAt: true, + updatedAt: true, + }, + }); + + if (config.enableAuditLogging) { + await logAuditEvent({ + userId: req.user.id, + action: "admin_user_created", + resource: `user:${user.id}`, + ipAddress: req.ip || req.connection.remoteAddress || undefined, + userAgent: req.headers["user-agent"] || undefined, + details: { createdUserId: user.id }, + }); + } + + res.status(201).json({ user }); + } catch (error) { + console.error("Create user error:", error); + res.status(500).json({ + error: "Internal server error", + message: "Failed to create user", + }); + } + }); + + router.patch("/users/:id", requireAuth, async (req: Request, res: Response) => { + try { + if (!(await ensureAuthEnabled(res))) return; + if (!requireCsrf(req, res)) return; + if (!requireAdmin(req, res)) return; + + const userId = String(req.params.id || "").trim(); + if (!userId) { + return res.status(400).json({ error: "Bad request", message: "Invalid user id" }); + } + + const parsed = adminUpdateUserSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: "Bad request", message: "Invalid update payload" }); + } + + if (userId === req.user.id && parsed.data.isActive === false) { + return res.status(409).json({ + error: "Conflict", + message: "You cannot deactivate your own account", + }); + } + + if (userId === req.user.id && parsed.data.role && parsed.data.role !== "ADMIN") { + return res.status(409).json({ + error: "Conflict", + message: "You cannot change your own role from ADMIN", + }); + } + + const current = await prisma.user.findUnique({ + where: { id: userId }, + select: { id: true, role: true, isActive: true }, + }); + + if (!current) { + return res.status(404).json({ error: "Not found", message: "User not found" }); + } + + const nextRole = typeof parsed.data.role === "undefined" ? current.role : parsed.data.role; + const nextActive = + typeof parsed.data.isActive === "undefined" ? current.isActive : parsed.data.isActive; + + const removingAdmin = + current.role === "ADMIN" && + current.isActive && + (nextRole !== "ADMIN" || nextActive === false); + + if (removingAdmin) { + const admins = await countActiveAdmins(); + if (admins <= 1) { + return res.status(409).json({ + error: "Conflict", + message: "There must be at least one active admin", + }); + } + } + + const data: Record = {}; + if (typeof parsed.data.username !== "undefined") data.username = parsed.data.username; + if (typeof parsed.data.name !== "undefined") data.name = sanitizeText(parsed.data.name, 100); + if (typeof parsed.data.role !== "undefined") data.role = parsed.data.role; + if (typeof parsed.data.mustResetPassword !== "undefined") + data.mustResetPassword = parsed.data.mustResetPassword; + if (typeof parsed.data.isActive !== "undefined") data.isActive = parsed.data.isActive; + + const updated = await prisma.user.update({ + where: { id: userId }, + data, + select: { + id: true, + username: true, + email: true, + name: true, + role: true, + mustResetPassword: true, + isActive: true, + createdAt: true, + updatedAt: true, + }, + }); + + if (config.enableAuditLogging) { + await logAuditEvent({ + userId: req.user.id, + action: "admin_user_updated", + resource: `user:${updated.id}`, + ipAddress: req.ip || req.connection.remoteAddress || undefined, + userAgent: req.headers["user-agent"] || undefined, + details: { updatedUserId: updated.id, fields: Object.keys(data) }, + }); + } + + res.json({ user: updated }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") { + return res.status(409).json({ + error: "Conflict", + message: "User with this username already exists", + }); + } + console.error("Update user error:", error); + res.status(500).json({ + error: "Internal server error", + message: "Failed to update user", + }); + } + }); + + router.post("/users/:id/reset-password", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => { + try { + if (!(await ensureAuthEnabled(res))) return; + if (!requireCsrf(req, res)) return; + if (!requireAdmin(req, res)) return; + + if (req.user.impersonatorId) { + return res.status(403).json({ + error: "Forbidden", + message: "Password resets are not allowed while impersonating", + }); + } + + const userId = String(req.params.id || "").trim(); + if (!userId) { + return res.status(400).json({ error: "Bad request", message: "Invalid user id" }); + } + + if (userId === req.user.id) { + return res.status(409).json({ + error: "Conflict", + message: "Use Profile -> Change Password for your own account", + }); + } + + const target = await prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + email: true, + username: true, + role: true, + isActive: true, + }, + }); + + if (!target) { + return res.status(404).json({ error: "Not found", message: "User not found" }); + } + + const tempPassword = generateTempPassword(); + const saltRounds = 10; + const passwordHash = await bcrypt.hash(tempPassword, saltRounds); + + await prisma.user.update({ + where: { id: target.id }, + data: { + passwordHash, + mustResetPassword: true, + isActive: true, + }, + }); + + try { + await prisma.refreshToken.updateMany({ + where: { userId: target.id, revoked: false }, + data: { revoked: true }, + }); + } catch { + if (process.env.NODE_ENV === "development") { + console.debug("Refresh token revocation skipped (feature disabled or table missing)"); + } + } + + await resetLoginAttemptKey(target.email.toLowerCase()); + + if (config.enableAuditLogging) { + await logAuditEvent({ + userId: req.user.id, + action: "admin_password_reset_generated", + resource: `user:${target.id}`, + ipAddress: req.ip || req.connection.remoteAddress || undefined, + userAgent: req.headers["user-agent"] || undefined, + details: { targetUserId: target.id, targetEmail: target.email }, + }); + } + + res.json({ + user: { id: target.id, email: target.email, username: target.username, role: target.role }, + tempPassword, + }); + } catch (error) { + console.error("Reset password error:", error); + res.status(500).json({ + error: "Internal server error", + message: "Failed to reset password", + }); + } + }); + + router.post("/impersonate", requireAuth, accountActionRateLimiter, async (req: Request, res: Response) => { + try { + if (!(await ensureAuthEnabled(res))) return; + if (!requireCsrf(req, res)) return; + const actingAdmin = await resolveImpersonationAdmin(req, res); + if (!actingAdmin) return; + + const parsed = impersonateSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: "Bad request", message: "Invalid impersonation payload" }); + } + + const target = + parsed.data.userId + ? await prisma.user.findUnique({ where: { id: parsed.data.userId } }) + : await findUserByIdentifier(parsed.data.identifier || ""); + + if (!target) { + return res.status(404).json({ error: "Not found", message: "User not found" }); + } + + if (target.id === actingAdmin.id) { + return res.status(409).json({ + error: "Conflict", + message: "Already using the admin account. Use stop impersonation to return.", + }); + } + + if (!target.isActive) { + return res.status(403).json({ error: "Forbidden", message: "Target user is inactive" }); + } + + const { accessToken, refreshToken } = generateTokens(target.id, target.email, { + impersonatorId: actingAdmin.id, + }); + setAuthCookies(req, res, { accessToken, refreshToken }); + + if (config.enableRefreshTokenRotation) { + const expiresAt = getRefreshTokenExpiresAt(); + try { + await prisma.refreshToken.create({ + data: { userId: target.id, token: hashTokenForStorage(refreshToken), expiresAt }, + }); + } catch { + if (process.env.NODE_ENV === "development") { + console.debug("Refresh token storage skipped (feature disabled or table missing)"); + } + } + } + + if (config.enableAuditLogging) { + await logAuditEvent({ + userId: actingAdmin.id, + action: "impersonation_started", + resource: `user:${target.id}`, + ipAddress: req.ip || req.connection.remoteAddress || undefined, + userAgent: req.headers["user-agent"] || undefined, + details: { targetUserId: target.id, initiatedFromImpersonation: Boolean(req.user?.impersonatorId) }, + }); + } + + res.json({ + user: { + id: target.id, + username: target.username ?? null, + email: target.email, + name: target.name, + role: target.role, + mustResetPassword: target.mustResetPassword, + }, + accessToken, + refreshToken, + }); + } catch (error) { + console.error("Impersonation error:", error); + res.status(500).json({ + error: "Internal server error", + message: "Failed to impersonate user", + }); + } + }); +}; diff --git a/backend/src/auth/authMode.test.ts b/backend/src/auth/authMode.test.ts new file mode 100644 index 00000000..fefc4c5a --- /dev/null +++ b/backend/src/auth/authMode.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { PrismaClient } from "../generated/client"; +import { + BOOTSTRAP_USER_ID, + DEFAULT_SYSTEM_CONFIG_ID, + createAuthModeService, +} from "./authMode"; + +const createPrismaMock = () => + ({ + systemConfig: { + findUnique: vi.fn(), + upsert: vi.fn(), + }, + user: { + upsert: vi.fn(), + }, + }) as unknown as PrismaClient; + +describe("authMode service", () => { + let now = 1_000_000; + + beforeEach(() => { + vi.restoreAllMocks(); + vi.spyOn(Date, "now").mockImplementation(() => now); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("caches authEnabled reads within TTL", async () => { + const prisma = createPrismaMock(); + const findUnique = prisma.systemConfig.findUnique as unknown as ReturnType; + const upsert = prisma.systemConfig.upsert as unknown as ReturnType; + findUnique + .mockResolvedValueOnce({ authEnabled: true }) + .mockResolvedValueOnce({ authEnabled: false }); + + const service = createAuthModeService(prisma, { authEnabledTtlMs: 5000 }); + + await expect(service.getAuthEnabled()).resolves.toBe(true); + + now += 1000; + await expect(service.getAuthEnabled()).resolves.toBe(true); + expect(findUnique).toHaveBeenCalledTimes(1); + + now += 6000; + await expect(service.getAuthEnabled()).resolves.toBe(false); + expect(findUnique).toHaveBeenCalledTimes(2); + expect(upsert).not.toHaveBeenCalled(); + }); + + it("clears auth cache when requested", async () => { + const prisma = createPrismaMock(); + const findUnique = prisma.systemConfig.findUnique as unknown as ReturnType; + const upsert = prisma.systemConfig.upsert as unknown as ReturnType; + findUnique.mockResolvedValue({ authEnabled: true }); + + const service = createAuthModeService(prisma); + await service.getAuthEnabled(); + service.clearAuthEnabledCache(); + await service.getAuthEnabled(); + + expect(findUnique).toHaveBeenCalledTimes(2); + expect(upsert).not.toHaveBeenCalled(); + }); + + it("falls back to upsert when system config row is missing", async () => { + const prisma = createPrismaMock(); + const findUnique = prisma.systemConfig.findUnique as unknown as ReturnType; + const upsert = prisma.systemConfig.upsert as unknown as ReturnType; + findUnique.mockResolvedValue(null); + upsert.mockResolvedValue({ authEnabled: false }); + + const service = createAuthModeService(prisma); + await expect(service.getAuthEnabled()).resolves.toBe(false); + + expect(findUnique).toHaveBeenCalledTimes(1); + expect(upsert).toHaveBeenCalledTimes(1); + }); + + it("creates/bootstrap user via upsert", async () => { + const prisma = createPrismaMock(); + const userUpsert = prisma.user.upsert as unknown as ReturnType; + userUpsert.mockResolvedValue({ + id: BOOTSTRAP_USER_ID, + email: "bootstrap@excalidash.local", + name: "Bootstrap Admin", + role: "ADMIN", + isActive: false, + mustResetPassword: true, + username: null, + }); + + const service = createAuthModeService(prisma); + const bootstrapUser = await service.getBootstrapActingUser(); + + expect(bootstrapUser.id).toBe(BOOTSTRAP_USER_ID); + expect(userUpsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: BOOTSTRAP_USER_ID }, + create: expect.objectContaining({ + id: BOOTSTRAP_USER_ID, + email: "bootstrap@excalidash.local", + role: "ADMIN", + }), + }) + ); + }); + + it("ensures system config defaults", async () => { + const prisma = createPrismaMock(); + const upsert = prisma.systemConfig.upsert as unknown as ReturnType; + upsert.mockResolvedValue({ authEnabled: false }); + + const service = createAuthModeService(prisma); + await service.ensureSystemConfig(); + + expect(upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: DEFAULT_SYSTEM_CONFIG_ID }, + create: expect.objectContaining({ + id: DEFAULT_SYSTEM_CONFIG_ID, + authEnabled: false, + registrationEnabled: false, + }), + }) + ); + }); +}); diff --git a/backend/src/auth/authMode.ts b/backend/src/auth/authMode.ts new file mode 100644 index 00000000..264ac24e --- /dev/null +++ b/backend/src/auth/authMode.ts @@ -0,0 +1,103 @@ +import { PrismaClient } from "../generated/client"; +import { config } from "../config"; + +export const BOOTSTRAP_USER_ID = "bootstrap-admin"; +export const DEFAULT_SYSTEM_CONFIG_ID = "default"; + +type AuthEnabledCache = { + value: boolean; + fetchedAt: number; +}; + +export type AuthModeService = ReturnType; + +export const createAuthModeService = ( + prisma: PrismaClient, + options?: { authEnabledTtlMs?: number } +) => { + const authEnabledTtlMs = options?.authEnabledTtlMs ?? 5000; + let authEnabledCache: AuthEnabledCache | null = null; + + const getSystemConfigAuthEnabled = async () => { + return prisma.systemConfig.findUnique({ + where: { id: DEFAULT_SYSTEM_CONFIG_ID }, + select: { authEnabled: true }, + }); + }; + + const ensureSystemConfig = async () => { + return prisma.systemConfig.upsert({ + where: { id: DEFAULT_SYSTEM_CONFIG_ID }, + update: {}, + create: { + id: DEFAULT_SYSTEM_CONFIG_ID, + authEnabled: config.authMode !== "local", + authOnboardingCompleted: false, + registrationEnabled: false, + authLoginRateLimitEnabled: true, + authLoginRateLimitWindowMs: 15 * 60 * 1000, + authLoginRateLimitMax: 20, + }, + }); + }; + + const getAuthEnabled = async (): Promise => { + if (config.authMode !== "local") { + const now = Date.now(); + authEnabledCache = { value: true, fetchedAt: now }; + return true; + } + + const now = Date.now(); + if (authEnabledCache && now - authEnabledCache.fetchedAt < authEnabledTtlMs) { + return authEnabledCache.value; + } + + const existingSystemConfig = await getSystemConfigAuthEnabled(); + if (existingSystemConfig) { + authEnabledCache = { value: existingSystemConfig.authEnabled, fetchedAt: now }; + return existingSystemConfig.authEnabled; + } + + const systemConfig = await ensureSystemConfig(); + authEnabledCache = { value: systemConfig.authEnabled, fetchedAt: now }; + return systemConfig.authEnabled; + }; + + const clearAuthEnabledCache = () => { + authEnabledCache = null; + }; + + const getBootstrapActingUser = async () => { + return prisma.user.upsert({ + where: { id: BOOTSTRAP_USER_ID }, + update: {}, + create: { + id: BOOTSTRAP_USER_ID, + email: "bootstrap@excalidash.local", + username: null, + passwordHash: "", + name: "Bootstrap Admin", + role: "ADMIN", + mustResetPassword: true, + isActive: false, + }, + select: { + id: true, + username: true, + email: true, + name: true, + role: true, + mustResetPassword: true, + isActive: true, + }, + }); + }; + + return { + ensureSystemConfig, + getAuthEnabled, + clearAuthEnabledCache, + getBootstrapActingUser, + }; +}; diff --git a/backend/src/auth/cookies.ts b/backend/src/auth/cookies.ts new file mode 100644 index 00000000..9322de53 --- /dev/null +++ b/backend/src/auth/cookies.ts @@ -0,0 +1,106 @@ +import type { Request, Response } from "express"; +import ms, { type StringValue } from "ms"; +import { config } from "../config"; + +export const ACCESS_TOKEN_COOKIE_NAME = "excalidash-access-token"; +export const REFRESH_TOKEN_COOKIE_NAME = "excalidash-refresh-token"; + +const DEFAULT_ACCESS_TTL_MS = 15 * 60 * 1000; +const DEFAULT_REFRESH_TTL_MS = 7 * 24 * 60 * 60 * 1000; + +const parseDurationToMs = (value: string, fallbackMs: number): number => { + const parsed = ms(value as StringValue); + if (typeof parsed === "number" && Number.isFinite(parsed) && parsed > 0) { + return parsed; + } + return fallbackMs; +}; + +const ACCESS_TOKEN_COOKIE_MAX_AGE_MS = parseDurationToMs( + config.jwtAccessExpiresIn, + DEFAULT_ACCESS_TTL_MS +); +const REFRESH_TOKEN_COOKIE_MAX_AGE_MS = parseDurationToMs( + config.jwtRefreshExpiresIn, + DEFAULT_REFRESH_TTL_MS +); + +const requestUsesHttps = (req: Request): boolean => { + if (req.secure) return true; + const forwardedProto = req.headers["x-forwarded-proto"]; + const raw = Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto; + const firstHop = String(raw || "") + .split(",")[0] + .trim() + .toLowerCase(); + return firstHop === "https"; +}; + +const shouldUseSecureCookies = (req: Request): boolean => requestUsesHttps(req); + +const baseCookieOptions = (req: Request) => ({ + httpOnly: true, + secure: shouldUseSecureCookies(req), + sameSite: "lax" as const, + path: "/", +}); + +export const setAuthCookies = ( + req: Request, + res: Response, + tokens: { accessToken: string; refreshToken: string } +): void => { + res.cookie(ACCESS_TOKEN_COOKIE_NAME, tokens.accessToken, { + ...baseCookieOptions(req), + maxAge: ACCESS_TOKEN_COOKIE_MAX_AGE_MS, + }); + res.cookie(REFRESH_TOKEN_COOKIE_NAME, tokens.refreshToken, { + ...baseCookieOptions(req), + maxAge: REFRESH_TOKEN_COOKIE_MAX_AGE_MS, + }); +}; + +export const setAccessTokenCookie = ( + req: Request, + res: Response, + accessToken: string +): void => { + res.cookie(ACCESS_TOKEN_COOKIE_NAME, accessToken, { + ...baseCookieOptions(req), + maxAge: ACCESS_TOKEN_COOKIE_MAX_AGE_MS, + }); +}; + +export const clearAuthCookies = (req: Request, res: Response): void => { + const options = baseCookieOptions(req); + res.clearCookie(ACCESS_TOKEN_COOKIE_NAME, options); + res.clearCookie(REFRESH_TOKEN_COOKIE_NAME, options); +}; + +export const parseCookieHeader = ( + cookieHeader: string | undefined +): Record => { + if (!cookieHeader) return {}; + + const cookies: Record = {}; + for (const part of cookieHeader.split(";")) { + const [rawKey, ...rawValueParts] = part.split("="); + if (!rawKey || rawValueParts.length === 0) continue; + const key = rawKey.trim(); + if (!key) continue; + const rawValue = rawValueParts.join("=").trim(); + try { + cookies[key] = decodeURIComponent(rawValue); + } catch { + cookies[key] = rawValue; + } + } + return cookies; +}; + +export const readCookie = (req: Request, cookieName: string): string | null => { + const cookies = parseCookieHeader(req.headers.cookie); + const value = cookies[cookieName]; + if (!value || value.trim().length === 0) return null; + return value; +}; diff --git a/backend/src/auth/coreRoutes.ts b/backend/src/auth/coreRoutes.ts new file mode 100644 index 00000000..d50ac1a2 --- /dev/null +++ b/backend/src/auth/coreRoutes.ts @@ -0,0 +1,1074 @@ +import express, { Request, Response } from "express"; +import bcrypt from "bcrypt"; +import jwt, { SignOptions } from "jsonwebtoken"; +import { Prisma, PrismaClient } from "../generated/client"; +import { StringValue } from "ms"; +import { logAuditEvent } from "../utils/audit"; +import { + authOnboardingChoiceSchema, + authEnabledToggleSchema, + loginSchema, + registerSchema, +} from "./schemas"; +import { getTokenLookupCandidates, hashTokenForStorage } from "./tokenSecurity"; + +type RegisterCoreRoutesDeps = { + router: express.Router; + prisma: PrismaClient; + requireAuth: express.RequestHandler; + optionalAuth: express.RequestHandler; + loginAttemptRateLimiter: express.RequestHandler; + ensureAuthEnabled: (res: Response) => Promise; + ensureSystemConfig: () => Promise<{ + id: string; + authEnabled: boolean; + authOnboardingCompleted: boolean; + registrationEnabled: boolean; + }>; + findUserByIdentifier: (identifier: string) => Promise<{ + id: string; + username: string | null; + email: string; + passwordHash: string; + name: string; + role: string; + isActive: boolean; + mustResetPassword: boolean; + } | null>; + sanitizeText: (input: unknown, maxLength?: number) => string; + requireCsrf: (req: Request, res: Response) => boolean; + isJwtPayload: (decoded: unknown) => decoded is { + userId: string; + email: string; + type: "access" | "refresh"; + impersonatorId?: string; + }; + config: { + authMode: "local" | "hybrid" | "oidc_enforced"; + jwtSecret: string; + jwtAccessExpiresIn: string; + enableRefreshTokenRotation: boolean; + enableAuditLogging: boolean; + oidc: { + enabled: boolean; + enforced: boolean; + providerName: string; + }; + }; + generateTokens: ( + userId: string, + email: string, + options?: { impersonatorId?: string } + ) => { accessToken: string; refreshToken: string }; + getRefreshTokenExpiresAt: () => Date; + isMissingRefreshTokenTableError: (error: unknown) => boolean; + bootstrapUserId: string; + defaultSystemConfigId: string; + clearAuthEnabledCache: () => void; + setAuthCookies: ( + req: Request, + res: Response, + tokens: { accessToken: string; refreshToken: string } + ) => void; + setAccessTokenCookie: (req: Request, res: Response, accessToken: string) => void; + clearAuthCookies: (req: Request, res: Response) => void; + readRefreshTokenFromRequest: (req: Request) => string | null; +}; + +class HttpError extends Error { + statusCode: number; + + constructor(statusCode: number, message: string) { + super(message); + this.statusCode = statusCode; + } +} + +export const registerCoreRoutes = (deps: RegisterCoreRoutesDeps) => { + const { + router, + prisma, + requireAuth, + optionalAuth, + loginAttemptRateLimiter, + ensureAuthEnabled, + ensureSystemConfig, + findUserByIdentifier, + sanitizeText, + requireCsrf, + isJwtPayload, + config, + generateTokens, + getRefreshTokenExpiresAt, + isMissingRefreshTokenTableError, + bootstrapUserId, + defaultSystemConfigId, + clearAuthEnabledCache, + setAuthCookies, + setAccessTokenCookie, + clearAuthCookies, + readRefreshTokenFromRequest, + } = deps; + const getUserTrashCollectionId = (userId: string): string => `trash:${userId}`; + const getAuthOnboardingStatus = async (systemConfig: { + authEnabled: boolean; + authOnboardingCompleted: boolean; + }) => { + const [activeUsers, drawingsCount, collectionsCount] = await Promise.all([ + prisma.user.count({ where: { isActive: true } }), + prisma.drawing.count(), + prisma.collection.count(), + ]); + const hasLegacyData = drawingsCount > 0 || collectionsCount > 0; + const needsChoice = + !systemConfig.authEnabled && + activeUsers === 0 && + !systemConfig.authOnboardingCompleted; + + return { + activeUsers, + hasLegacyData, + needsChoice, + mode: hasLegacyData ? "migration" : "fresh", + } as const; + }; + + const ensureBootstrapUserExists = async (): Promise => { + const bootstrap = await prisma.user.findUnique({ + where: { id: bootstrapUserId }, + select: { id: true }, + }); + if (bootstrap) return; + + await prisma.user.create({ + data: { + id: bootstrapUserId, + email: "bootstrap@excalidash.local", + username: null, + passwordHash: "", + name: "Bootstrap Admin", + role: "ADMIN", + mustResetPassword: true, + isActive: false, + }, + }); + }; + + router.post("/register", loginAttemptRateLimiter, async (req: Request, res: Response) => { + try { + if (!(await ensureAuthEnabled(res))) return; + if (config.authMode === "oidc_enforced") { + return res.status(403).json({ + error: "Forbidden", + message: "Local registration is disabled in OIDC enforced mode", + }); + } + if (!requireCsrf(req, res)) return; + const parsed = registerSchema.safeParse(req.body); + + if (!parsed.success) { + const summarizedIssues = parsed.error.issues.map((issue) => ({ + code: issue.code, + path: issue.path.join("."), + message: issue.message, + })); + console.warn("[auth/register] validation failed", { + issues: summarizedIssues, + requestId: req.headers["x-request-id"], + ip: req.ip || req.connection.remoteAddress || "unknown", + }); + return res.status(400).json({ + error: "Validation error", + message: "Invalid registration data", + }); + } + + const { email, password, name, username } = parsed.data; + + const systemConfig = await ensureSystemConfig(); + + const activeUsers = await prisma.user.count({ where: { isActive: true } }); + const bootstrapUser = await prisma.user.findUnique({ + where: { id: bootstrapUserId }, + select: { id: true, isActive: true }, + }); + const isBootstrapFlow = + Boolean(bootstrapUser) && + bootstrapUser?.isActive === false && + activeUsers === 0 && + bootstrapUser.id === bootstrapUserId; + + if (isBootstrapFlow) { + const saltRounds = 10; + const passwordHash = await bcrypt.hash(password, saltRounds); + const sanitizedName = sanitizeText(name, 100); + + const existingEmailUser = await prisma.user.findUnique({ + where: { email }, + select: { id: true }, + }); + if (existingEmailUser && existingEmailUser.id !== bootstrapUserId) { + return res.status(409).json({ + error: "Conflict", + message: "User with this email already exists", + }); + } + + if (username) { + const existingUsernameUser = await prisma.user.findFirst({ + where: { username }, + select: { id: true }, + }); + if (existingUsernameUser && existingUsernameUser.id !== bootstrapUserId) { + return res.status(409).json({ + error: "Conflict", + message: "User with this username already exists", + }); + } + } + + let user: { + id: string; + email: string; + name: string; + role: string; + mustResetPassword: boolean; + }; + try { + user = await prisma.user.update({ + where: { id: bootstrapUserId }, + data: { + email, + username: username ?? null, + passwordHash, + name: sanitizedName, + role: "ADMIN", + mustResetPassword: false, + isActive: true, + }, + select: { + id: true, + email: true, + name: true, + role: true, + mustResetPassword: true, + }, + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") { + return res.status(409).json({ + error: "Conflict", + message: "User with this email or username already exists", + }); + } + throw error; + } + + const trashCollectionId = getUserTrashCollectionId(user.id); + const existingTrash = await prisma.collection.findFirst({ + where: { id: trashCollectionId, userId: user.id }, + }); + if (!existingTrash) { + await prisma.collection.create({ + data: { + id: trashCollectionId, + name: "Trash", + userId: user.id, + }, + }); + } + + const { accessToken, refreshToken } = generateTokens(user.id, user.email); + setAuthCookies(req, res, { accessToken, refreshToken }); + + if (config.enableRefreshTokenRotation) { + const expiresAt = getRefreshTokenExpiresAt(); + await prisma.refreshToken.create({ + data: { userId: user.id, token: hashTokenForStorage(refreshToken), expiresAt }, + }); + } + + if (config.enableAuditLogging) { + await logAuditEvent({ + userId: user.id, + action: "bootstrap_admin", + ipAddress: req.ip || req.connection.remoteAddress || undefined, + userAgent: req.headers["user-agent"] || undefined, + }); + } + + return res.status(201).json({ + user: { + id: user.id, + email: user.email, + name: user.name, + role: user.role, + mustResetPassword: user.mustResetPassword, + }, + accessToken, + refreshToken, + registrationEnabled: systemConfig.registrationEnabled, + bootstrapped: true, + }); + } + + if (!systemConfig.registrationEnabled) { + return res.status(403).json({ + error: "Forbidden", + message: "User registration is disabled.", + }); + } + + const existingUser = await prisma.user.findUnique({ + where: { email }, + }); + + if (existingUser) { + return res.status(409).json({ + error: "Conflict", + message: "User with this email already exists", + }); + } + + if (username) { + const existingUsername = await prisma.user.findFirst({ + where: { username }, + select: { id: true }, + }); + if (existingUsername) { + return res.status(409).json({ + error: "Conflict", + message: "User with this username already exists", + }); + } + } + + const saltRounds = 10; + const passwordHash = await bcrypt.hash(password, saltRounds); + const sanitizedName = sanitizeText(name, 100); + + const user = await prisma.user.create({ + data: { + email, + passwordHash, + name: sanitizedName, + username: username ?? null, + }, + select: { + id: true, + email: true, + name: true, + role: true, + mustResetPassword: true, + createdAt: true, + }, + }); + + const trashCollectionId = getUserTrashCollectionId(user.id); + const existingTrash = await prisma.collection.findFirst({ + where: { id: trashCollectionId, userId: user.id }, + }); + if (!existingTrash) { + await prisma.collection.create({ + data: { + id: trashCollectionId, + name: "Trash", + userId: user.id, + }, + }); + } + + const { accessToken, refreshToken } = generateTokens(user.id, user.email); + setAuthCookies(req, res, { accessToken, refreshToken }); + + if (config.enableRefreshTokenRotation) { + const expiresAt = getRefreshTokenExpiresAt(); + + try { + await prisma.refreshToken.create({ + data: { + userId: user.id, + token: hashTokenForStorage(refreshToken), + expiresAt, + }, + }); + } catch (error) { + if (isMissingRefreshTokenTableError(error)) { + console.error("Refresh token rotation is enabled but refresh token storage is unavailable"); + return res.status(503).json({ + error: "Service unavailable", + message: "Refresh token storage is unavailable. Please run database migrations.", + }); + } + throw error; + } + } + + if (config.enableAuditLogging) { + await logAuditEvent({ + userId: user.id, + action: "user_registered", + ipAddress: req.ip || req.connection.remoteAddress || undefined, + userAgent: req.headers["user-agent"] || undefined, + }); + } + + res.status(201).json({ + user: { + id: user.id, + email: user.email, + name: user.name, + role: user.role, + mustResetPassword: user.mustResetPassword, + }, + accessToken, + refreshToken, + registrationEnabled: systemConfig.registrationEnabled, + }); + } catch (error) { + console.error("Registration error:", error); + res.status(500).json({ + error: "Internal server error", + message: "Failed to register user", + }); + } + }); + + router.post("/login", loginAttemptRateLimiter, async (req: Request, res: Response) => { + try { + if (!(await ensureAuthEnabled(res))) return; + if (config.authMode === "oidc_enforced") { + return res.status(403).json({ + error: "Forbidden", + message: "Local login is disabled in OIDC enforced mode", + }); + } + const parsed = loginSchema.safeParse(req.body); + + if (!parsed.success) { + return res.status(400).json({ + error: "Validation error", + message: "Invalid login credentials", + }); + } + + const identifier = parsed.data.email || parsed.data.username || parsed.data.identifier || ""; + const { password } = parsed.data; + + const bootstrapUser = await prisma.user.findUnique({ + where: { id: bootstrapUserId }, + select: { id: true, isActive: true }, + }); + if (bootstrapUser && bootstrapUser.isActive === false) { + const activeUsers = await prisma.user.count({ where: { isActive: true } }); + if (activeUsers === 0) { + return res.status(409).json({ + error: "Bootstrap required", + message: "Initial admin account has not been configured yet. Register to bootstrap.", + }); + } + } + + const user = await findUserByIdentifier(identifier); + + if (!user) { + return res.status(401).json({ + error: "Unauthorized", + message: "Invalid email or password", + }); + } + + if (!user.isActive) { + return res.status(403).json({ + error: "Forbidden", + message: "Account is inactive", + }); + } + + const passwordValid = await bcrypt.compare(password, user.passwordHash); + + if (!passwordValid) { + if (config.enableAuditLogging) { + await logAuditEvent({ + action: "login_failed", + ipAddress: req.ip || req.connection.remoteAddress || undefined, + userAgent: req.headers["user-agent"] || undefined, + details: { identifier }, + }); + } + + return res.status(401).json({ + error: "Unauthorized", + message: "Invalid email or password", + }); + } + + const { accessToken, refreshToken } = generateTokens(user.id, user.email); + setAuthCookies(req, res, { accessToken, refreshToken }); + + if (config.enableRefreshTokenRotation) { + const expiresAt = getRefreshTokenExpiresAt(); + + try { + await prisma.refreshToken.create({ + data: { + userId: user.id, + token: hashTokenForStorage(refreshToken), + expiresAt, + }, + }); + } catch (error) { + if (isMissingRefreshTokenTableError(error)) { + console.error("Refresh token rotation is enabled but refresh token storage is unavailable"); + return res.status(503).json({ + error: "Service unavailable", + message: "Refresh token storage is unavailable. Please run database migrations.", + }); + } + throw error; + } + } + + if (config.enableAuditLogging) { + await logAuditEvent({ + userId: user.id, + action: "login", + ipAddress: req.ip || req.connection.remoteAddress || undefined, + userAgent: req.headers["user-agent"] || undefined, + }); + } + + res.json({ + user: { + id: user.id, + email: user.email, + name: user.name, + role: user.role, + mustResetPassword: user.mustResetPassword, + }, + accessToken, + refreshToken, + }); + } catch (error) { + console.error("Login error:", error); + res.status(500).json({ + error: "Internal server error", + message: "Failed to login", + }); + } + }); + + router.post("/refresh", async (req: Request, res: Response) => { + try { + if (!(await ensureAuthEnabled(res))) return; + const oldRefreshTokenFromBody = + typeof req.body?.refreshToken === "string" ? req.body.refreshToken : null; + const oldRefreshToken = oldRefreshTokenFromBody || readRefreshTokenFromRequest(req); + + if (!oldRefreshToken || typeof oldRefreshToken !== "string") { + return res.status(400).json({ + error: "Bad request", + message: "Refresh token required", + }); + } + + try { + const decoded = jwt.verify(oldRefreshToken, config.jwtSecret); + + if (!isJwtPayload(decoded)) { + return res.status(401).json({ + error: "Unauthorized", + message: "Invalid token payload", + }); + } + + if (decoded.type !== "refresh") { + return res.status(401).json({ + error: "Unauthorized", + message: "Invalid token type", + }); + } + + const user = await prisma.user.findUnique({ + where: { id: decoded.userId }, + select: { id: true, email: true, isActive: true }, + }); + + if (!user || !user.isActive) { + return res.status(401).json({ + error: "Unauthorized", + message: "User account not found or inactive", + }); + } + + if (config.enableRefreshTokenRotation) { + try { + const { accessToken, refreshToken: newRefreshToken } = generateTokens( + user.id, + user.email, + { impersonatorId: decoded.impersonatorId } + ); + + const expiresAt = getRefreshTokenExpiresAt(); + + await prisma.$transaction(async (tx) => { + const storedToken = await tx.refreshToken.findFirst({ + where: { + OR: getTokenLookupCandidates(oldRefreshToken).map((candidate) => ({ + token: candidate, + })), + }, + }); + + if (!storedToken || storedToken.userId !== user.id || storedToken.revoked) { + throw new HttpError(401, "Invalid or revoked refresh token"); + } + + if (new Date() > storedToken.expiresAt) { + throw new HttpError(401, "Refresh token has expired"); + } + + const revoked = await tx.refreshToken.updateMany({ + where: { id: storedToken.id, revoked: false }, + data: { revoked: true }, + }); + if (revoked.count !== 1) { + throw new HttpError(401, "Invalid or revoked refresh token"); + } + + await tx.refreshToken.create({ + data: { + userId: user.id, + token: hashTokenForStorage(newRefreshToken), + expiresAt, + }, + }); + }); + + setAuthCookies(req, res, { + accessToken, + refreshToken: newRefreshToken, + }); + return res.json({ + accessToken, + refreshToken: newRefreshToken, + }); + } catch (error) { + if (error instanceof HttpError) { + return res.status(error.statusCode).json({ + error: "Unauthorized", + message: error.message, + }); + } + + if (isMissingRefreshTokenTableError(error)) { + console.error("Refresh token rotation is enabled but refresh token storage is unavailable"); + return res.status(503).json({ + error: "Service unavailable", + message: "Refresh token storage is unavailable. Please run database migrations.", + }); + } else { + console.error("Refresh token rotation error:", error); + return res.status(500).json({ + error: "Internal server error", + message: "Failed to rotate refresh token", + }); + } + } + } + + const signOptions: SignOptions = { + expiresIn: config.jwtAccessExpiresIn as StringValue, + }; + const accessToken = jwt.sign( + { + userId: user.id, + email: user.email, + type: "access", + impersonatorId: decoded.impersonatorId, + }, + config.jwtSecret, + signOptions + ); + + setAccessTokenCookie(req, res, accessToken); + res.json({ accessToken }); + } catch { + return res.status(401).json({ + error: "Unauthorized", + message: "Invalid or expired refresh token", + }); + } + } catch (error) { + console.error("Refresh token error:", error); + res.status(500).json({ + error: "Internal server error", + message: "Failed to refresh token", + }); + } + }); + + router.post("/logout", optionalAuth, async (req: Request, res: Response) => { + try { + if (!(await ensureAuthEnabled(res))) return; + if (!requireCsrf(req, res)) return; + + clearAuthCookies(req, res); + + if (config.enableRefreshTokenRotation && req.user?.id) { + await prisma.refreshToken.updateMany({ + where: { userId: req.user.id, revoked: false }, + data: { revoked: true }, + }); + } + + return res.json({ ok: true }); + } catch (error) { + console.error("Logout error:", error); + return res.status(500).json({ + error: "Internal server error", + message: "Failed to logout", + }); + } + }); + + router.post("/stop-impersonation", requireAuth, async (req: Request, res: Response) => { + try { + if (!(await ensureAuthEnabled(res))) return; + if (!requireCsrf(req, res)) return; + if (!req.user) { + return res.status(401).json({ + error: "Unauthorized", + message: "User not authenticated", + }); + } + + if (!req.user.impersonatorId) { + return res.status(409).json({ + error: "Conflict", + message: "Not currently impersonating another user", + }); + } + + const impersonator = await prisma.user.findUnique({ + where: { id: req.user.impersonatorId }, + select: { + id: true, + username: true, + email: true, + name: true, + role: true, + mustResetPassword: true, + isActive: true, + }, + }); + + if (!impersonator || !impersonator.isActive || impersonator.role !== "ADMIN") { + return res.status(403).json({ + error: "Forbidden", + message: "Impersonator account is unavailable or no longer authorized", + }); + } + + const { accessToken, refreshToken } = generateTokens( + impersonator.id, + impersonator.email + ); + setAuthCookies(req, res, { accessToken, refreshToken }); + + if (config.enableRefreshTokenRotation) { + const expiresAt = getRefreshTokenExpiresAt(); + try { + await prisma.refreshToken.create({ + data: { + userId: impersonator.id, + token: hashTokenForStorage(refreshToken), + expiresAt, + }, + }); + } catch (error) { + if (isMissingRefreshTokenTableError(error)) { + return res.status(503).json({ + error: "Service unavailable", + message: "Refresh token storage is unavailable. Please run database migrations.", + }); + } + throw error; + } + } + + return res.json({ + user: { + id: impersonator.id, + username: impersonator.username, + email: impersonator.email, + name: impersonator.name, + role: impersonator.role, + mustResetPassword: impersonator.mustResetPassword, + }, + accessToken, + refreshToken, + }); + } catch (error) { + console.error("Stop impersonation error:", error); + return res.status(500).json({ + error: "Internal server error", + message: "Failed to stop impersonation", + }); + } + }); + + router.get("/me", requireAuth, async (req: Request, res: Response) => { + try { + if (!(await ensureAuthEnabled(res))) return; + if (!req.user) { + return res.status(401).json({ + error: "Unauthorized", + message: "User not authenticated", + }); + } + + const user = await prisma.user.findUnique({ + where: { id: req.user.id }, + select: { + id: true, + username: true, + email: true, + name: true, + role: true, + mustResetPassword: true, + createdAt: true, + updatedAt: true, + }, + }); + + if (!user) { + return res.status(404).json({ + error: "Not found", + message: "User not found", + }); + } + + res.json({ user }); + } catch (error) { + console.error("Get user error:", error); + res.status(500).json({ + error: "Internal server error", + message: "Failed to get user information", + }); + } + }); + + router.get("/status", optionalAuth, async (req: Request, res: Response) => { + try { + const systemConfig = await ensureSystemConfig(); + const onboarding = await getAuthOnboardingStatus(systemConfig); + const effectiveAuthEnabled = + config.authMode !== "local" ? true : systemConfig.authEnabled; + const onboardingRequired = config.authMode === "local" ? onboarding.needsChoice : false; + const onboardingMode = config.authMode === "local" ? onboarding.mode : null; + if (!effectiveAuthEnabled) { + return res.json({ + enabled: false, + authenticated: false, + authEnabled: false, + authMode: config.authMode, + oidcEnabled: config.oidc.enabled, + oidcEnforced: config.oidc.enforced, + oidcProvider: config.oidc.providerName, + registrationEnabled: false, + bootstrapRequired: false, + authOnboardingRequired: onboardingRequired, + authOnboardingMode: onboardingMode, + authOnboardingRecommended: onboardingRequired ? "enable" : null, + user: null, + }); + } + + const bootstrapUser = await prisma.user.findUnique({ + where: { id: bootstrapUserId }, + select: { id: true, isActive: true }, + }); + const bootstrapRequired = + !config.oidc.enforced && + Boolean(bootstrapUser && bootstrapUser.isActive === false) && + onboarding.activeUsers === 0; + + res.json({ + enabled: true, + authEnabled: true, + authMode: config.authMode, + oidcEnabled: config.oidc.enabled, + oidcEnforced: config.oidc.enforced, + oidcProvider: config.oidc.providerName, + authenticated: Boolean(req.user), + registrationEnabled: systemConfig.registrationEnabled, + bootstrapRequired, + authOnboardingRequired: onboardingRequired, + authOnboardingMode: onboardingMode, + authOnboardingRecommended: onboardingRequired ? "enable" : null, + user: req.user + ? { + id: req.user.id, + username: req.user.username ?? null, + email: req.user.email, + name: req.user.name, + role: req.user.role, + mustResetPassword: req.user.mustResetPassword ?? false, + impersonatorId: req.user.impersonatorId, + } + : null, + }); + } catch (error) { + console.error("Auth status error:", error); + res.status(500).json({ + error: "Internal server error", + message: "Failed to fetch auth status", + }); + } + }); + + router.post("/onboarding-choice", optionalAuth, async (req: Request, res: Response) => { + try { + if (config.authMode !== "local") { + return res.status(409).json({ + error: "Conflict", + message: "Onboarding choice is managed by AUTH_MODE configuration", + }); + } + if (!requireCsrf(req, res)) return; + const parsed = authOnboardingChoiceSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ + error: "Bad request", + message: "Invalid onboarding choice payload", + }); + } + + const systemConfig = await ensureSystemConfig(); + const onboarding = await getAuthOnboardingStatus(systemConfig); + if (!onboarding.needsChoice) { + return res.status(409).json({ + error: "Conflict", + message: "Authentication onboarding is already completed", + }); + } + + const nextAuthEnabled = parsed.data.enableAuth; + if (nextAuthEnabled) { + await ensureBootstrapUserExists(); + } + + const updated = await prisma.systemConfig.upsert({ + where: { id: defaultSystemConfigId }, + update: { + authEnabled: nextAuthEnabled, + authOnboardingCompleted: true, + }, + create: { + id: defaultSystemConfigId, + authEnabled: nextAuthEnabled, + authOnboardingCompleted: true, + registrationEnabled: systemConfig.registrationEnabled, + }, + }); + + clearAuthEnabledCache(); + + return res.json({ + authEnabled: updated.authEnabled, + authOnboardingCompleted: updated.authOnboardingCompleted, + bootstrapRequired: Boolean(nextAuthEnabled), + }); + } catch (error) { + console.error("Auth onboarding choice error:", error); + return res.status(500).json({ + error: "Internal server error", + message: "Failed to apply authentication onboarding choice", + }); + } + }); + + router.post("/auth-enabled", requireAuth, async (req: Request, res: Response) => { + try { + if (config.authMode === "oidc_enforced") { + return res.status(409).json({ + error: "Conflict", + message: "Authentication mode is managed by AUTH_MODE=oidc_enforced", + }); + } + if (!requireCsrf(req, res)) return; + if (!req.user) { + return res + .status(401) + .json({ error: "Unauthorized", message: "User not authenticated" }); + } + if (req.user.role !== "ADMIN") { + return res + .status(403) + .json({ error: "Forbidden", message: "Admin access required" }); + } + + const parsed = authEnabledToggleSchema.safeParse(req.body); + if (!parsed.success) { + return res + .status(400) + .json({ error: "Bad request", message: "Invalid toggle payload" }); + } + + const systemConfig = await ensureSystemConfig(); + const current = systemConfig.authEnabled; + const next = parsed.data.enabled; + + if (!current && next) { + const bootstrap = await prisma.user.findUnique({ + where: { id: bootstrapUserId }, + select: { id: true }, + }); + if (!bootstrap) { + await prisma.user.create({ + data: { + id: bootstrapUserId, + email: "bootstrap@excalidash.local", + username: null, + passwordHash: "", + name: "Bootstrap Admin", + role: "ADMIN", + mustResetPassword: true, + isActive: false, + }, + }); + } + } + + const updated = await prisma.systemConfig.upsert({ + where: { id: defaultSystemConfigId }, + update: { authEnabled: next, authOnboardingCompleted: true }, + create: { + id: defaultSystemConfigId, + authEnabled: next, + authOnboardingCompleted: true, + registrationEnabled: systemConfig.registrationEnabled, + }, + }); + clearAuthEnabledCache(); + + const bootstrapUser = await prisma.user.findUnique({ + where: { id: bootstrapUserId }, + select: { id: true, isActive: true }, + }); + const activeUsers = await prisma.user.count({ where: { isActive: true } }); + const bootstrapRequired = + Boolean(updated.authEnabled && bootstrapUser && bootstrapUser.isActive === false) && + activeUsers === 0; + + res.json({ authEnabled: updated.authEnabled, bootstrapRequired }); + } catch (error) { + console.error("Auth enabled toggle error:", error); + res.status(500).json({ + error: "Internal server error", + message: "Failed to update authentication mode", + }); + } + }); +}; diff --git a/backend/src/auth/oidcRoutes.ts b/backend/src/auth/oidcRoutes.ts new file mode 100644 index 00000000..2a23dc94 --- /dev/null +++ b/backend/src/auth/oidcRoutes.ts @@ -0,0 +1,543 @@ +import crypto from "crypto"; +import express, { Request, Response } from "express"; +import { Prisma, PrismaClient } from "../generated/client"; +import { generators, Issuer } from "openid-client"; +import { logAuditEvent } from "../utils/audit"; +import { hashTokenForStorage } from "./tokenSecurity"; + +const OIDC_FLOW_COOKIE_NAME = "excalidash-oidc-flow"; +const OIDC_PROVIDER_KEY = "oidc"; +const OIDC_FLOW_TTL_MS = 10 * 60 * 1000; + +type OidcFlowPayload = { + state: string; + nonce: string; + codeVerifier: string; + returnTo: string; + expiresAt: number; +}; + +type OidcUser = { + id: string; + username: string | null; + email: string; + name: string; + role: string; + mustResetPassword: boolean; + isActive: boolean; +}; + +type RegisterOidcRoutesDeps = { + router: express.Router; + prisma: PrismaClient; + ensureAuthEnabled: (res: Response) => Promise; + sanitizeText: (input: unknown, maxLength?: number) => string; + generateTokens: ( + userId: string, + email: string, + options?: { impersonatorId?: string } + ) => { accessToken: string; refreshToken: string }; + setAuthCookies: ( + req: Request, + res: Response, + tokens: { accessToken: string; refreshToken: string } + ) => void; + getRefreshTokenExpiresAt: () => Date; + isMissingRefreshTokenTableError: (error: unknown) => boolean; + config: { + authMode: "local" | "hybrid" | "oidc_enforced"; + jwtSecret: string; + enableRefreshTokenRotation: boolean; + enableAuditLogging: boolean; + oidc: { + enabled: boolean; + enforced: boolean; + providerName: string; + issuerUrl: string | null; + clientId: string | null; + clientSecret: string | null; + redirectUri: string | null; + scopes: string; + emailClaim: string; + emailVerifiedClaim: string; + requireEmailVerified: boolean; + jitProvisioning: boolean; + firstUserAdmin: boolean; + }; + }; +}; + +const requestUsesHttps = (req: Request): boolean => { + if (req.secure) return true; + const forwardedProto = req.headers["x-forwarded-proto"]; + const raw = Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto; + const firstHop = String(raw || "") + .split(",")[0] + .trim() + .toLowerCase(); + return firstHop === "https"; +}; + +const normalizeEmail = (value: string): string => value.trim().toLowerCase(); + +const sanitizeReturnTo = (rawValue: unknown): string => { + if (typeof rawValue !== "string") return "/"; + const value = rawValue.trim(); + if (!value.startsWith("/")) return "/"; + if (value.startsWith("//")) return "/"; + if (/[\r\n]/.test(value)) return "/"; + if (value.length > 2048) return "/"; + return value; +}; + +const base64UrlEncode = (value: Buffer | string): string => { + const buffer = typeof value === "string" ? Buffer.from(value, "utf8") : value; + return buffer + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); +}; + +const base64UrlDecode = (value: string): Buffer => { + const normalized = value.replace(/-/g, "+").replace(/_/g, "/"); + const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4); + return Buffer.from(padded, "base64"); +}; + +const signFlowPayload = (encodedPayload: string, secret: string): string => + base64UrlEncode( + crypto.createHmac("sha256", secret).update(encodedPayload, "utf8").digest() + ); + +const encodeFlowPayload = (payload: OidcFlowPayload, secret: string): string => { + const encodedPayload = base64UrlEncode(JSON.stringify(payload)); + const signature = signFlowPayload(encodedPayload, secret); + return `${encodedPayload}.${signature}`; +}; + +const decodeFlowPayload = ( + cookieValue: string | null, + secret: string +): OidcFlowPayload | null => { + if (!cookieValue) return null; + const [encodedPayload, providedSignature] = cookieValue.split("."); + if (!encodedPayload || !providedSignature) return null; + + try { + const expectedSignature = signFlowPayload(encodedPayload, secret); + const expectedBuffer = Buffer.from(expectedSignature, "utf8"); + const providedBuffer = Buffer.from(providedSignature, "utf8"); + if (expectedBuffer.length !== providedBuffer.length) return null; + if (!crypto.timingSafeEqual(expectedBuffer, providedBuffer)) return null; + + const parsed = JSON.parse(base64UrlDecode(encodedPayload).toString("utf8")) as Partial; + if ( + typeof parsed.state !== "string" || + typeof parsed.nonce !== "string" || + typeof parsed.codeVerifier !== "string" || + typeof parsed.returnTo !== "string" || + typeof parsed.expiresAt !== "number" + ) { + return null; + } + + if (Date.now() > parsed.expiresAt) return null; + return { + state: parsed.state, + nonce: parsed.nonce, + codeVerifier: parsed.codeVerifier, + returnTo: sanitizeReturnTo(parsed.returnTo), + expiresAt: parsed.expiresAt, + }; + } catch { + return null; + } +}; + +const readStringClaim = (claims: Record, key: string): string | null => { + const value = claims[key]; + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +}; + +const readBooleanClaim = (claims: Record, key: string): boolean | null => { + const value = claims[key]; + if (typeof value === "boolean") return value; + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + if (normalized === "true" || normalized === "1") return true; + if (normalized === "false" || normalized === "0") return false; + } + return null; +}; + +const getOidcErrorMessage = (errorCode: string): string => { + switch (errorCode) { + case "missing_flow": + return "Missing or expired OIDC login flow. Please try again."; + case "provider_error": + return "OIDC provider returned an error."; + case "missing_subject": + return "OIDC response is missing required subject claim."; + case "missing_email": + return "OIDC response is missing required email claim."; + case "unverified_email": + return "OIDC account email is not verified."; + case "account_inactive": + return "Your account is inactive."; + case "provisioning_disabled": + return "No account found and automatic provisioning is disabled."; + case "callback_failed": + return "OIDC callback validation failed."; + default: + return "OIDC sign-in failed."; + } +}; + +export const registerOidcRoutes = (deps: RegisterOidcRoutesDeps) => { + const { + router, + prisma, + ensureAuthEnabled, + sanitizeText, + generateTokens, + setAuthCookies, + getRefreshTokenExpiresAt, + isMissingRefreshTokenTableError, + config, + } = deps; + + if (!config.oidc.enabled) { + return; + } + + let oidcClientPromise: Promise | null = null; + + const getOidcClient = async () => { + if (!config.oidc.issuerUrl || !config.oidc.clientId || !config.oidc.clientSecret) { + throw new Error("OIDC is enabled but provider configuration is incomplete"); + } + if (!oidcClientPromise) { + oidcClientPromise = (async () => { + const issuer = await Issuer.discover(config.oidc.issuerUrl as string); + return new issuer.Client({ + client_id: config.oidc.clientId as string, + client_secret: config.oidc.clientSecret as string, + redirect_uris: [config.oidc.redirectUri as string], + response_types: ["code"], + }); + })(); + } + + try { + return await oidcClientPromise; + } catch (error) { + oidcClientPromise = null; + throw error; + } + }; + + const clearOidcFlowCookie = (req: Request, res: Response) => { + res.clearCookie(OIDC_FLOW_COOKIE_NAME, { + httpOnly: true, + sameSite: "lax", + secure: requestUsesHttps(req), + path: "/", + }); + }; + + const setOidcFlowCookie = (req: Request, res: Response, payload: OidcFlowPayload) => { + const encoded = encodeFlowPayload(payload, config.jwtSecret); + res.cookie(OIDC_FLOW_COOKIE_NAME, encoded, { + httpOnly: true, + sameSite: "lax", + secure: requestUsesHttps(req), + path: "/", + maxAge: OIDC_FLOW_TTL_MS, + }); + }; + + const redirectToLoginWithError = ( + req: Request, + res: Response, + errorCode: string, + returnTo?: string + ) => { + const search = new URLSearchParams(); + search.set("oidcError", errorCode); + search.set("oidcErrorMessage", getOidcErrorMessage(errorCode)); + if (returnTo) { + search.set("returnTo", sanitizeReturnTo(returnTo)); + } + + clearOidcFlowCookie(req, res); + return res.redirect(`/login?${search.toString()}`); + }; + + const userSelect = { + id: true, + username: true, + email: true, + name: true, + role: true, + mustResetPassword: true, + isActive: true, + } as const; + + const ensureTrashCollection = async ( + tx: Prisma.TransactionClient, + userId: string + ) => { + const trashCollectionId = `trash:${userId}`; + const existingTrash = await tx.collection.findFirst({ + where: { id: trashCollectionId, userId }, + select: { id: true }, + }); + if (!existingTrash) { + await tx.collection.create({ + data: { + id: trashCollectionId, + name: "Trash", + userId, + }, + }); + } + }; + + router.get("/oidc/start", async (req: Request, res: Response) => { + try { + if (!(await ensureAuthEnabled(res))) return; + const client = await getOidcClient(); + const state = generators.state(); + const nonce = generators.nonce(); + const codeVerifier = generators.codeVerifier(); + const codeChallenge = generators.codeChallenge(codeVerifier); + const returnTo = sanitizeReturnTo(req.query.returnTo); + + setOidcFlowCookie(req, res, { + state, + nonce, + codeVerifier, + returnTo, + expiresAt: Date.now() + OIDC_FLOW_TTL_MS, + }); + + const authorizationUrl = client.authorizationUrl({ + scope: config.oidc.scopes, + response_type: "code", + state, + nonce, + code_challenge: codeChallenge, + code_challenge_method: "S256", + }); + + return res.redirect(authorizationUrl); + } catch (error) { + console.error("OIDC start error:", error); + return redirectToLoginWithError(req, res, "callback_failed"); + } + }); + + router.get("/oidc/callback", async (req: Request, res: Response) => { + const cookieValue = (() => { + const cookieHeader = req.headers.cookie; + if (!cookieHeader) return null; + for (const part of cookieHeader.split(";")) { + const [rawKey, ...rawValueParts] = part.split("="); + if (!rawKey || rawValueParts.length === 0) continue; + if (rawKey.trim() !== OIDC_FLOW_COOKIE_NAME) continue; + const rawValue = rawValueParts.join("=").trim(); + try { + return decodeURIComponent(rawValue); + } catch { + return rawValue; + } + } + return null; + })(); + const flow = decodeFlowPayload(cookieValue, config.jwtSecret); + clearOidcFlowCookie(req, res); + + if (!flow) { + return redirectToLoginWithError(req, res, "missing_flow"); + } + + try { + if (!(await ensureAuthEnabled(res))) return; + + if (typeof req.query.error === "string") { + return redirectToLoginWithError(req, res, "provider_error", flow.returnTo); + } + + const client = await getOidcClient(); + const params = client.callbackParams(req); + const tokenSet = await client.callback( + config.oidc.redirectUri as string, + params, + { + state: flow.state, + nonce: flow.nonce, + code_verifier: flow.codeVerifier, + } + ); + const claims = tokenSet.claims() as Record; + const issuer = client.issuer.issuer; + const subject = readStringClaim(claims, "sub"); + if (!subject) { + return redirectToLoginWithError(req, res, "missing_subject", flow.returnTo); + } + + const rawEmail = + readStringClaim(claims, config.oidc.emailClaim) ?? + readStringClaim(claims, "email"); + if (!rawEmail) { + return redirectToLoginWithError(req, res, "missing_email", flow.returnTo); + } + const normalizedEmail = normalizeEmail(rawEmail); + + const emailVerified = readBooleanClaim(claims, config.oidc.emailVerifiedClaim); + if (config.oidc.requireEmailVerified && emailVerified !== true) { + return redirectToLoginWithError(req, res, "unverified_email", flow.returnTo); + } + + const user = await prisma.$transaction(async (tx) => { + const linkedIdentity = await tx.authIdentity.findUnique({ + where: { + issuer_subject: { + issuer, + subject, + }, + }, + include: { + user: { + select: userSelect, + }, + }, + }); + if (linkedIdentity) { + await tx.authIdentity.update({ + where: { id: linkedIdentity.id }, + data: { + lastLoginAt: new Date(), + emailAtLink: normalizedEmail, + }, + }); + return linkedIdentity.user; + } + + const existingUser = await tx.user.findUnique({ + where: { email: normalizedEmail }, + select: userSelect, + }); + + if (existingUser && !existingUser.isActive) { + return existingUser; + } + + let resolvedUser: OidcUser; + if (existingUser) { + resolvedUser = existingUser; + } else { + if (!config.oidc.jitProvisioning) { + throw new Error("OIDC provisioning disabled"); + } + + const activeUsers = await tx.user.count({ where: { isActive: true } }); + const defaultName = + readStringClaim(claims, "name") ?? + readStringClaim(claims, "preferred_username") ?? + normalizedEmail.split("@")[0] ?? + "User"; + const sanitizedName = sanitizeText(defaultName, 100) || "User"; + const role = + activeUsers === 0 && config.oidc.firstUserAdmin ? "ADMIN" : "USER"; + + resolvedUser = await tx.user.create({ + data: { + email: normalizedEmail, + username: null, + passwordHash: "", + name: sanitizedName, + role, + mustResetPassword: false, + isActive: true, + }, + select: userSelect, + }); + + await ensureTrashCollection(tx, resolvedUser.id); + } + + await tx.authIdentity.create({ + data: { + userId: resolvedUser.id, + provider: OIDC_PROVIDER_KEY, + issuer, + subject, + emailAtLink: normalizedEmail, + lastLoginAt: new Date(), + }, + }); + + return resolvedUser; + }); + + if (!user.isActive) { + return redirectToLoginWithError(req, res, "account_inactive", flow.returnTo); + } + + const { accessToken, refreshToken } = generateTokens(user.id, user.email); + setAuthCookies(req, res, { accessToken, refreshToken }); + + if (config.enableRefreshTokenRotation) { + const expiresAt = getRefreshTokenExpiresAt(); + try { + await prisma.refreshToken.create({ + data: { + userId: user.id, + token: hashTokenForStorage(refreshToken), + expiresAt, + }, + }); + } catch (error) { + if (isMissingRefreshTokenTableError(error)) { + return redirectToLoginWithError(req, res, "callback_failed", flow.returnTo); + } + throw error; + } + } + + if (config.enableAuditLogging) { + await logAuditEvent({ + userId: user.id, + action: "oidc_login", + ipAddress: req.ip || req.connection.remoteAddress || undefined, + userAgent: req.headers["user-agent"] || undefined, + details: { + provider: config.oidc.providerName, + issuer, + }, + }); + } + + return res.redirect(flow.returnTo || "/"); + } catch (error) { + if ( + error instanceof Error && + /OIDC provisioning disabled/i.test(error.message) + ) { + return redirectToLoginWithError(req, res, "provisioning_disabled", flow.returnTo); + } + + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") { + // Retry path for identity creation race: redirect and let user retry once. + return redirectToLoginWithError(req, res, "callback_failed", flow.returnTo); + } + + console.error("OIDC callback error:", error); + return redirectToLoginWithError(req, res, "callback_failed", flow.returnTo); + } + }); +}; diff --git a/backend/src/auth/schemas.ts b/backend/src/auth/schemas.ts new file mode 100644 index 00000000..86a63825 --- /dev/null +++ b/backend/src/auth/schemas.ts @@ -0,0 +1,116 @@ +import { z } from "zod"; + +const productionStrongPasswordMessage = + "Password must be at least 12 characters and include upper, lower, number, and symbol"; + +const strongPasswordPattern = + /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9]).{12,100}$/; + +const passwordSchema = z + .string() + .min(8) + .max(100) + .refine( + (value) => + process.env.NODE_ENV !== "production" || strongPasswordPattern.test(value), + { message: productionStrongPasswordMessage } + ); + +export const registerSchema = z.object({ + username: z.string().trim().min(3).max(50).optional(), + email: z.string().email().toLowerCase().trim(), + password: passwordSchema, + name: z.string().trim().min(1).max(100), +}); + +export const loginSchema = z + .object({ + identifier: z.string().trim().min(1).max(255).optional(), + email: z.string().email().toLowerCase().trim().optional(), + username: z.string().trim().min(1).max(255).optional(), + password: z.string(), + }) + .refine((data) => Boolean(data.identifier || data.email || data.username), { + message: "identifier/email/username is required", + }); + +export const registrationToggleSchema = z.object({ + enabled: z.boolean(), +}); + +export const adminRoleUpdateSchema = z.object({ + identifier: z.string().trim().min(1).max(255), + role: z.enum(["ADMIN", "USER"]), +}); + +export const authEnabledToggleSchema = z.object({ + enabled: z.boolean(), +}); + +export const authOnboardingChoiceSchema = z.object({ + enableAuth: z.boolean(), +}); + +export const adminCreateUserSchema = z.object({ + username: z.string().trim().min(3).max(50).optional(), + email: z.string().email().toLowerCase().trim(), + password: passwordSchema, + name: z.string().trim().min(1).max(100), + role: z.enum(["ADMIN", "USER"]).optional(), + mustResetPassword: z.boolean().optional(), + isActive: z.boolean().optional(), +}); + +export const adminUpdateUserSchema = z.object({ + username: z.string().trim().min(3).max(50).nullable().optional(), + name: z.string().trim().min(1).max(100).optional(), + role: z.enum(["ADMIN", "USER"]).optional(), + mustResetPassword: z.boolean().optional(), + isActive: z.boolean().optional(), +}); + +export const impersonateSchema = z + .object({ + userId: z.string().trim().min(1).optional(), + identifier: z.string().trim().min(1).optional(), + }) + .refine((data) => Boolean(data.userId || data.identifier), { + message: "userId/identifier is required", + }); + +export const loginRateLimitUpdateSchema = z.object({ + enabled: z.boolean(), + windowMs: z.number().int().min(10_000).max(24 * 60 * 60 * 1000), + max: z.number().int().min(1).max(10_000), +}); + +export const loginRateLimitResetSchema = z.object({ + identifier: z.string().trim().min(1).max(255), +}); + +export const passwordResetRequestSchema = z.object({ + email: z.string().email().toLowerCase().trim(), +}); + +export const passwordResetConfirmSchema = z.object({ + token: z.string().min(1), + password: passwordSchema, +}); + +export const updateProfileSchema = z.object({ + name: z.string().trim().min(1).max(100), +}); + +export const updateEmailSchema = z.object({ + email: z.string().email().toLowerCase().trim(), + currentPassword: z.string().min(1).max(100), +}); + +export const changePasswordSchema = z.object({ + currentPassword: z.string(), + newPassword: passwordSchema, +}); + +export const mustResetPasswordSchema = z.object({ + newPassword: passwordSchema, +}); diff --git a/backend/src/auth/tokenSecurity.ts b/backend/src/auth/tokenSecurity.ts new file mode 100644 index 00000000..712b4d48 --- /dev/null +++ b/backend/src/auth/tokenSecurity.ts @@ -0,0 +1,11 @@ +import crypto from "crypto"; + +export const hashTokenForStorage = (token: string): string => + crypto.createHash("sha256").update(token, "utf8").digest("hex"); + +export const getTokenLookupCandidates = (token: string): string[] => { + const candidates = new Set(); + candidates.add(token); + candidates.add(hashTokenForStorage(token)); + return [...candidates]; +}; diff --git a/backend/src/config.ts b/backend/src/config.ts new file mode 100644 index 00000000..ddb60f73 --- /dev/null +++ b/backend/src/config.ts @@ -0,0 +1,245 @@ +/** + * Configuration validation and environment variable management + */ +import dotenv from "dotenv"; +import crypto from "crypto"; +import path from "path"; + +dotenv.config(); + +interface Config { + port: number; + nodeEnv: string; + databaseUrl?: string; + frontendUrl?: string; + apiBasePath: string; + authMode: AuthMode; + jwtSecret: string; + jwtAccessExpiresIn: string; + jwtRefreshExpiresIn: string; + rateLimitMaxRequests: number; + csrfMaxRequests: number; + csrfSecret: string | null; + oidc: OidcConfig; + // Feature flags - all default to false for backward compatibility + enablePasswordReset: boolean; + enableRefreshTokenRotation: boolean; + enableAuditLogging: boolean; +} + +export type AuthMode = "local" | "hybrid" | "oidc_enforced"; + +interface OidcConfig { + enabled: boolean; + enforced: boolean; + providerName: string; + issuerUrl: string | null; + clientId: string | null; + clientSecret: string | null; + redirectUri: string | null; + scopes: string; + emailClaim: string; + emailVerifiedClaim: string; + requireEmailVerified: boolean; + jitProvisioning: boolean; + firstUserAdmin: boolean; +} + +const getOptionalEnv = (key: string, defaultValue: string): string => { + return process.env[key] || defaultValue; +}; + +const getOptionalTrimmedEnv = (key: string): string | null => { + const raw = process.env[key]; + if (!raw) return null; + const trimmed = raw.trim(); + return trimmed.length > 0 ? trimmed : null; +}; + +const resolveJwtSecret = (nodeEnv: string): string => { + const provided = process.env.JWT_SECRET; + if (provided && provided.trim().length > 0) { + return provided; + } + + if (nodeEnv === "production") { + throw new Error("Missing required environment variable: JWT_SECRET"); + } + + const generated = crypto.randomBytes(32).toString("hex"); + console.warn( + "[security] JWT_SECRET is not set (non-production). Using an ephemeral secret; tokens will be invalidated on restart." + ); + return generated; +}; + +const parseFrontendUrl = (raw: string | undefined): string | undefined => { + if (!raw || raw.trim().length === 0) return undefined; + const normalized = raw + .split(",") + .map((origin) => origin.trim()) + .filter((origin) => origin.length > 0) + .join(","); + return normalized.length > 0 ? normalized : undefined; +}; + +const parseApiBasePath = (raw: string | undefined): string => { + const fallback = "/api"; + if (!raw || raw.trim().length === 0) return fallback; + + const trimmed = raw.trim(); + if (trimmed === "/") return "/"; + + const withLeadingSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; + const withoutTrailingSlash = + withLeadingSlash.length > 1 && withLeadingSlash.endsWith("/") + ? withLeadingSlash.slice(0, -1) + : withLeadingSlash; + + return withoutTrailingSlash.length > 0 ? withoutTrailingSlash : fallback; +}; + +const resolveDatabaseUrl = (rawUrl?: string) => { + const backendRoot = path.resolve(__dirname, "../"); + const defaultDbPath = path.resolve(backendRoot, "prisma/dev.db"); + + if (!rawUrl || rawUrl.trim().length === 0) { + return `file:${defaultDbPath}`; + } + + if (!rawUrl.startsWith("file:")) { + return rawUrl; + } + + const filePath = rawUrl.replace(/^file:/, ""); + const prismaDir = path.resolve(backendRoot, "prisma"); + const normalizedRelative = filePath.replace(/^\.\/?/, ""); + const hasLeadingPrismaDir = + normalizedRelative === "prisma" || normalizedRelative.startsWith("prisma/"); + + const absolutePath = path.isAbsolute(filePath) + ? filePath + : path.resolve(hasLeadingPrismaDir ? backendRoot : prismaDir, normalizedRelative); + + return `file:${absolutePath}`; +}; + +// Ensure DATABASE_URL is resolved before any PrismaClient is created. +process.env.DATABASE_URL = resolveDatabaseUrl(process.env.DATABASE_URL); + +const getOptionalBoolean = (key: string, defaultValue: boolean): boolean => { + const value = process.env[key]; + if (!value) return defaultValue; + return value.toLowerCase() === "true" || value === "1"; +}; + +const getRequiredEnvNumber = (key: string, defaultValue: number): number => { + const value = process.env[key]; + if (!value) return defaultValue; + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`Invalid value for environment variable ${key}: must be a positive number`); + } + return parsed; +}; + +const parseAuthMode = (rawValue: string | undefined): AuthMode => { + const normalized = (rawValue || "local").trim().toLowerCase(); + if (normalized === "local" || normalized === "hybrid" || normalized === "oidc_enforced") { + return normalized; + } + throw new Error( + "Invalid AUTH_MODE. Expected one of: local, hybrid, oidc_enforced" + ); +}; + +const resolveOidcConfig = (authMode: AuthMode): OidcConfig => { + const issuerUrl = getOptionalTrimmedEnv("OIDC_ISSUER_URL"); + const clientId = getOptionalTrimmedEnv("OIDC_CLIENT_ID"); + const clientSecret = getOptionalTrimmedEnv("OIDC_CLIENT_SECRET"); + const redirectUri = getOptionalTrimmedEnv("OIDC_REDIRECT_URI"); + const requiredWhenEnabled = { + OIDC_ISSUER_URL: issuerUrl, + OIDC_CLIENT_ID: clientId, + OIDC_CLIENT_SECRET: clientSecret, + OIDC_REDIRECT_URI: redirectUri, + }; + + const enabled = authMode !== "local"; + const missingRequired = Object.entries(requiredWhenEnabled) + .filter(([, value]) => !value) + .map(([key]) => key); + if (enabled && missingRequired.length > 0) { + throw new Error( + `AUTH_MODE=${authMode} requires OIDC configuration. Missing: ${missingRequired.join(", ")}` + ); + } + + if (!enabled) { + const hasOidcVars = Object.values(requiredWhenEnabled).some((value) => Boolean(value)); + if (hasOidcVars) { + console.warn("[config] AUTH_MODE=local; ignoring OIDC_* provider settings."); + } + } + + return { + enabled, + enforced: authMode === "oidc_enforced", + providerName: getOptionalEnv("OIDC_PROVIDER_NAME", "OIDC"), + issuerUrl, + clientId, + clientSecret, + redirectUri, + scopes: getOptionalEnv("OIDC_SCOPES", "openid profile email"), + emailClaim: getOptionalEnv("OIDC_EMAIL_CLAIM", "email"), + emailVerifiedClaim: getOptionalEnv("OIDC_EMAIL_VERIFIED_CLAIM", "email_verified"), + requireEmailVerified: getOptionalBoolean("OIDC_REQUIRE_EMAIL_VERIFIED", true), + jitProvisioning: getOptionalBoolean("OIDC_JIT_PROVISIONING", true), + firstUserAdmin: getOptionalBoolean("OIDC_FIRST_USER_ADMIN", true), + }; +}; + +const resolvedAuthMode = parseAuthMode(process.env.AUTH_MODE); + +export const config: Config = { + port: getRequiredEnvNumber("PORT", 8000), + nodeEnv: getOptionalEnv("NODE_ENV", "development"), + databaseUrl: process.env.DATABASE_URL, + frontendUrl: parseFrontendUrl(process.env.FRONTEND_URL), + apiBasePath: parseApiBasePath(process.env.API_BASE_PATH), + authMode: resolvedAuthMode, + jwtSecret: resolveJwtSecret(getOptionalEnv("NODE_ENV", "development")), + jwtAccessExpiresIn: getOptionalEnv("JWT_ACCESS_EXPIRES_IN", "15m"), + jwtRefreshExpiresIn: getOptionalEnv("JWT_REFRESH_EXPIRES_IN", "7d"), + rateLimitMaxRequests: getRequiredEnvNumber("RATE_LIMIT_MAX_REQUESTS", 1000), + csrfMaxRequests: getRequiredEnvNumber("CSRF_MAX_REQUESTS", 60), + csrfSecret: process.env.CSRF_SECRET || null, + oidc: resolveOidcConfig(resolvedAuthMode), + // Feature flags - disabled by default for backward compatibility + enablePasswordReset: getOptionalBoolean("ENABLE_PASSWORD_RESET", false), + enableRefreshTokenRotation: getOptionalBoolean("ENABLE_REFRESH_TOKEN_ROTATION", false), + enableAuditLogging: getOptionalBoolean("ENABLE_AUDIT_LOGGING", false), +}; + +// Validate JWT_SECRET strength in production +if (config.nodeEnv === "production") { + const normalizedSecret = config.jwtSecret.trim(); + const insecureJwtSecretPlaceholders = new Set([ + "your-secret-key-change-in-production", + "change-this-secret-in-production-min-32-chars", + ]); + + if (config.jwtSecret.length < 32) { + throw new Error("JWT_SECRET must be at least 32 characters long in production"); + } + if ( + insecureJwtSecretPlaceholders.has(normalizedSecret) + ) { + throw new Error("JWT_SECRET must be changed from placeholder/default value in production"); + } + if (config.oidc.enabled && config.oidc.redirectUri && !/^https:\/\//i.test(config.oidc.redirectUri)) { + throw new Error("OIDC_REDIRECT_URI must be HTTPS in production"); + } +} + +console.log("Configuration validated successfully"); diff --git a/backend/src/db/prisma.ts b/backend/src/db/prisma.ts new file mode 100644 index 00000000..809095e6 --- /dev/null +++ b/backend/src/db/prisma.ts @@ -0,0 +1,14 @@ +import { PrismaClient } from "../generated/client"; + +declare global { + // eslint-disable-next-line no-var + var __excalidashPrisma: PrismaClient | undefined; +} + +const prismaClient = globalThis.__excalidashPrisma ?? new PrismaClient(); + +if (process.env.NODE_ENV !== "production") { + globalThis.__excalidashPrisma = prismaClient; +} + +export { prismaClient as prisma }; diff --git a/backend/src/index.ts b/backend/src/index.ts index 65adce94..1587286b 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,6 +1,5 @@ import express from "express"; import cors from "cors"; -import dotenv from "dotenv"; import path from "path"; import fs from "fs"; import { promises as fsPromises } from "fs"; @@ -8,69 +7,36 @@ import { createServer } from "http"; import { Server } from "socket.io"; import { Worker } from "worker_threads"; import multer from "multer"; -import archiver from "archiver"; import { z } from "zod"; +import helmet from "helmet"; +import rateLimit from "express-rate-limit"; +import { v4 as uuidv4 } from "uuid"; import { PrismaClient, Prisma } from "./generated/client"; import { sanitizeDrawingData, validateImportedDrawing, sanitizeText, - sanitizeSvg, + sanitizePreview, elementSchema, appStateSchema, - createCsrfToken, - validateCsrfToken, - getCsrfTokenHeader, - getOriginFromReferer, } from "./security"; - -dotenv.config(); +import { config } from "./config"; +import { authModeService, requireAuth } from "./middleware/auth"; +import { errorHandler, asyncHandler } from "./middleware/errorHandler"; +import authRouter from "./auth"; +import { logAuditEvent } from "./utils/audit"; +import { registerDashboardRoutes } from "./routes/dashboard"; +import { registerImportExportRoutes } from "./routes/importExport"; +import { prisma } from "./db/prisma"; +import { createDrawingsCacheStore } from "./server/drawingsCache"; +import { createCollabSessionManager } from "./server/collabSession"; +import { registerCsrfProtection } from "./server/csrf"; +import { registerSocketHandlers } from "./server/socket"; +import { getClientIp } from "./utils/clientIp"; const backendRoot = path.resolve(__dirname, "../"); -const defaultDbPath = path.resolve(backendRoot, "prisma/dev.db"); -const resolveDatabaseUrl = (rawUrl?: string) => { - if (!rawUrl || rawUrl.trim().length === 0) { - return `file:${defaultDbPath}`; - } - - if (!rawUrl.startsWith("file:")) { - return rawUrl; - } - - const filePath = rawUrl.replace(/^file:/, ""); - - // Prisma treats relative SQLite paths as relative to the schema directory - // (i.e. `backend/prisma/schema.prisma`). Historically this project used - // `file:./prisma/dev.db`, which Prisma interprets as `prisma/prisma/dev.db`. - // To keep runtime and migrations aligned: - // - Prefer resolving relative paths against `backend/prisma` - // - But if the path already includes a leading `prisma/`, resolve from repo root - const prismaDir = path.resolve(backendRoot, "prisma"); - const normalizedRelative = filePath.replace(/^\.\/?/, ""); - const hasLeadingPrismaDir = - normalizedRelative === "prisma" || - normalizedRelative.startsWith("prisma/"); - - const absolutePath = path.isAbsolute(filePath) - ? filePath - : path.resolve(hasLeadingPrismaDir ? backendRoot : prismaDir, normalizedRelative); - - return `file:${absolutePath}`; -}; - -process.env.DATABASE_URL = resolveDatabaseUrl(process.env.DATABASE_URL); console.log("Resolved DATABASE_URL:", process.env.DATABASE_URL); -// Helper to get the resolved database file path -const getResolvedDbPath = (): string => { - const dbUrl = process.env.DATABASE_URL || `file:${defaultDbPath}`; - if (dbUrl.startsWith("file:")) { - return dbUrl.replace(/^file:/, ""); - } - // Fallback to default for non-file URLs (e.g., Postgres) - return defaultDbPath; -}; - const normalizeOrigins = (rawOrigins?: string | null): string[] => { const fallback = "http://localhost:6767"; if (!rawOrigins || rawOrigins.trim().length === 0) { @@ -93,31 +59,47 @@ const normalizeOrigins = (rawOrigins?: string | null): string[] => { return parsed.length > 0 ? parsed : [fallback]; }; -const allowedOrigins = normalizeOrigins(process.env.FRONTEND_URL); +const allowedOrigins = normalizeOrigins(config.frontendUrl); console.log("Allowed origins:", allowedOrigins); +console.log("API base path:", config.apiBasePath); + +const isDev = (process.env.NODE_ENV || "development") !== "production"; +const isLocalDevOrigin = (origin: string): boolean => { + // Allow any localhost/127.0.0.1 port in dev (Vite often picks a free port). + return ( + /^http:\/\/localhost:\d+$/i.test(origin) || + /^http:\/\/127\.0\.0\.1:\d+$/i.test(origin) + ); +}; -const uploadDir = path.resolve(__dirname, "../uploads"); +const isAllowedOrigin = (origin?: string): boolean => { + if (!origin) return true; // non-browser clients / same-origin + if (allowedOrigins.includes(origin)) return true; + if (isDev && isLocalDevOrigin(origin)) return true; + return false; +}; -const moveFile = async (source: string, destination: string) => { +const uploadDir = path.resolve(__dirname, "../uploads"); +const MAX_UPLOAD_SIZE_BYTES = 100 * 1024 * 1024; +const MAX_PAGE_SIZE = 200; +const MAX_IMPORT_ARCHIVE_ENTRIES = 6000; +const MAX_IMPORT_COLLECTIONS = 1000; +const MAX_IMPORT_DRAWINGS = 5000; +const MAX_IMPORT_MANIFEST_BYTES = 2 * 1024 * 1024; +const MAX_IMPORT_DRAWING_BYTES = 5 * 1024 * 1024; +const MAX_IMPORT_TOTAL_EXTRACTED_BYTES = 120 * 1024 * 1024; + +let cachedBackendVersion: string | null = null; +const getBackendVersion = (): string => { + if (cachedBackendVersion) return cachedBackendVersion; try { - await fsPromises.rename(source, destination); - } catch (error) { - const err = error as NodeJS.ErrnoException; - if (!err || err.code !== "EXDEV") { - throw error; - } - - await fsPromises - .unlink(destination) - .catch((unlinkError: NodeJS.ErrnoException) => { - if (unlinkError && unlinkError.code !== "ENOENT") { - throw unlinkError; - } - }); - - await fsPromises.copyFile(source, destination); - await fsPromises.unlink(source); + const raw = fs.readFileSync(path.resolve(backendRoot, "package.json"), "utf8"); + const parsed = JSON.parse(raw) as { version?: string }; + cachedBackendVersion = typeof parsed.version === "string" ? parsed.version : "unknown"; + } catch { + cachedBackendVersion = "unknown"; } + return cachedBackendVersion; }; const initializeUploadDir = async () => { @@ -130,16 +112,19 @@ const initializeUploadDir = async () => { const app = express(); -// Trust proxy headers (X-Forwarded-For, X-Real-IP) from nginx -// Required for correct client IP detection when running behind a reverse proxy -// Fix for issue #38: Use 'true' to handle multiple proxy layers (e.g., Traefik, Synology NAS) -// This ensures Express extracts the real client IP from the leftmost X-Forwarded-For value -const trustProxyConfig = process.env.TRUST_PROXY || "true"; -const trustProxyValue = trustProxyConfig === "true" - ? true - : trustProxyConfig === "false" - ? false - : parseInt(trustProxyConfig, 10) || 1; +// Trust proxy headers (X-Forwarded-For, X-Real-IP) only when explicitly configured. +// Safe default is disabled to avoid spoofed client IPs when running without a trusted proxy. +// Set TRUST_PROXY=1 (or a specific hop count) when deploying behind reverse proxies. +const trustProxyConfig = (process.env.TRUST_PROXY ?? "false").trim(); +const parsedProxyHops = Number.parseInt(trustProxyConfig, 10); +const trustProxyValue = + trustProxyConfig === "true" + ? true + : trustProxyConfig === "false" + ? false + : Number.isFinite(parsedProxyHops) && parsedProxyHops > 0 + ? parsedProxyHops + : false; app.set("trust proxy", trustProxyValue); if (trustProxyValue === true) { @@ -150,13 +135,16 @@ if (trustProxyValue === true) { const httpServer = createServer(app); const io = new Server(httpServer, { + path: + config.apiBasePath === "/" + ? "/socket.io" + : `${config.apiBasePath}/socket.io`, cors: { - origin: allowedOrigins, + origin: (origin, cb) => cb(null, isAllowedOrigin(origin ?? undefined)), credentials: true, }, maxHttpBufferSize: 1e8, }); -const prisma = new PrismaClient(); const parseJsonField = ( rawValue: string | null | undefined, fallback: T @@ -180,58 +168,48 @@ const DRAWINGS_CACHE_TTL_MS = (() => { } return parsed; })(); -type DrawingsCacheEntry = { body: Buffer; expiresAt: number }; -const drawingsCache = new Map(); - -const buildDrawingsCacheKey = (keyParts: { - searchTerm: string; - collectionFilter: string; - includeData: boolean; -}) => - JSON.stringify([ - keyParts.searchTerm, - keyParts.collectionFilter, - keyParts.includeData ? "full" : "summary", - ]); +const { + buildDrawingsCacheKey, + getCachedDrawingsBody, + cacheDrawingsResponse, + invalidateDrawingsCache, +} = createDrawingsCacheStore(DRAWINGS_CACHE_TTL_MS); +const collabSessionManager = createCollabSessionManager({ prisma }); + +const getUserTrashCollectionId = (userId: string): string => `trash:${userId}`; + +const ensureTrashCollection = async ( + db: Prisma.TransactionClient | PrismaClient, + userId: string +): Promise => { + const trashCollectionId = getUserTrashCollectionId(userId); + const trashCollection = await db.collection.findFirst({ + where: { id: trashCollectionId, userId }, + }); -const getCachedDrawingsBody = (key: string): Buffer | null => { - const entry = drawingsCache.get(key); - if (!entry) return null; - if (Date.now() > entry.expiresAt) { - drawingsCache.delete(key); - return null; + if (!trashCollection) { + await db.collection.create({ + data: { + id: trashCollectionId, + name: "Trash", + userId, + }, + }); } - return entry.body; -}; -const cacheDrawingsResponse = (key: string, payload: any): Buffer => { - const body = Buffer.from(JSON.stringify(payload)); - drawingsCache.set(key, { - body, - expiresAt: Date.now() + DRAWINGS_CACHE_TTL_MS, + // Legacy migration: move this user's drawings off global "trash". + await db.drawing.updateMany({ + where: { userId, collectionId: "trash" }, + data: { collectionId: trashCollectionId }, }); - return body; -}; - -const invalidateDrawingsCache = () => { - drawingsCache.clear(); }; -setInterval(() => { - const now = Date.now(); - for (const [key, entry] of drawingsCache.entries()) { - if (now > entry.expiresAt) { - drawingsCache.delete(key); - } - } -}, 60_000).unref(); - -const PORT = process.env.PORT || 8000; +const PORT = config.port; const upload = multer({ dest: uploadDir, limits: { - fileSize: 100 * 1024 * 1024, + fileSize: MAX_UPLOAD_SIZE_BYTES, files: 1, }, fileFilter: (req, file, cb) => { @@ -247,236 +225,135 @@ const upload = multer({ }, }); +// Request ID middleware (must be early in the chain) +app.use((req, res, next) => { + const requestId = uuidv4(); + req.headers["x-request-id"] = requestId; + res.setHeader("X-Request-ID", requestId); + next(); +}); + +// HTTPS enforcement in production only when configured frontend origins use HTTPS. +const shouldEnforceHttps = + config.nodeEnv === "production" && + allowedOrigins.some((origin) => origin.toLowerCase().startsWith("https://")); + +if (shouldEnforceHttps) { + app.use((req, res, next) => { + if (req.header("x-forwarded-proto") !== "https") { + res.redirect(`https://${req.header("host")}${req.url}`); + } else { + next(); + } + }); +} + +// Helmet security headers +app.use( + helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + scriptSrc: [ + "'self'", + "'unsafe-inline'", // Required for Excalidraw + "'unsafe-eval'", // Required for Excalidraw + "https://cdn.jsdelivr.net", + "https://unpkg.com", + ], + styleSrc: [ + "'self'", + "'unsafe-inline'", // Required for Excalidraw + "https://fonts.googleapis.com", + ], + fontSrc: ["'self'", "https://fonts.gstatic.com"], + imgSrc: ["'self'", "data:", "blob:", "https:"], + connectSrc: ["'self'", "ws:", "wss:"], + frameAncestors: ["'none'"], + }, + }, + hsts: { + maxAge: 31536000, // 1 year + includeSubDomains: true, + preload: true, + }, + }) +); + app.use( cors({ - origin: allowedOrigins, + origin: (origin, cb) => cb(null, isAllowedOrigin(origin ?? undefined)), credentials: true, - allowedHeaders: ["Content-Type", "Authorization", "x-csrf-token"], - exposedHeaders: ["x-csrf-token"], + allowedHeaders: ["Content-Type", "Authorization", "x-csrf-token", "x-imported-file", "x-share-token"], + exposedHeaders: ["x-csrf-token", "x-request-id"], }) ); app.use(express.json({ limit: "50mb" })); app.use(express.urlencoded({ extended: true, limit: "50mb" })); +// Request logging middleware app.use((req, res, next) => { + const requestId = req.headers["x-request-id"] || "unknown"; const contentLength = req.headers["content-length"]; + const userEmail = req.user?.email || "anonymous"; + if (contentLength) { const sizeInMB = parseInt(contentLength, 10) / 1024 / 1024; if (sizeInMB > 10) { console.log( `[LARGE REQUEST] ${req.method} ${req.path} - ${sizeInMB.toFixed( 2 - )}MB - Content-Length: ${contentLength} bytes` + )}MB - User: ${userEmail} - RequestID: ${requestId}` ); } } - next(); -}); - -app.use((req, res, next) => { - res.setHeader("X-Content-Type-Options", "nosniff"); - res.setHeader("X-Frame-Options", "DENY"); - res.setHeader("X-XSS-Protection", "1; mode=block"); - res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin"); - res.setHeader( - "Permissions-Policy", - "geolocation=(), microphone=(), camera=()" - ); - - res.setHeader( - "Content-Security-Policy", - "default-src 'self'; " + - "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net https://unpkg.com; " + - "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " + - "font-src 'self' https://fonts.gstatic.com; " + - "img-src 'self' data: blob: https:; " + - "connect-src 'self' ws: wss:; " + - "frame-ancestors 'none';" + + console.log( + `[REQUEST] ${req.method} ${req.path} - User: ${userEmail} - IP: ${req.ip} - RequestID: ${requestId}` ); - + next(); }); -const requestCounts = new Map(); const RATE_LIMIT_WINDOW = 15 * 60 * 1000; -setInterval(() => { - const now = Date.now(); - for (const [ip, data] of requestCounts.entries()) { - if (now > data.resetTime) { - requestCounts.delete(ip); - } - } -}, 5 * 60 * 1000).unref(); - -const RATE_LIMIT_MAX_REQUESTS = (() => { - const parsed = Number(process.env.RATE_LIMIT_MAX_REQUESTS); - if (!Number.isFinite(parsed) || parsed <= 0) { - return 1000; - } - return parsed; -})(); - -app.use((req, res, next) => { - const ip = req.ip || req.connection.remoteAddress || "unknown"; - const now = Date.now(); - const clientData = requestCounts.get(ip); - - if (!clientData || now > clientData.resetTime) { - requestCounts.set(ip, { count: 1, resetTime: now + RATE_LIMIT_WINDOW }); - return next(); - } - - if (clientData.count >= RATE_LIMIT_MAX_REQUESTS) { - return res.status(429).json({ - error: "Rate limit exceeded", - message: "Too many requests, please try again later", - }); - } - - clientData.count++; - next(); +// General rate limiting with express-rate-limit +const generalRateLimiter = rateLimit({ + windowMs: RATE_LIMIT_WINDOW, + max: config.rateLimitMaxRequests, + message: { + error: "Rate limit exceeded", + message: "Too many requests, please try again later", + }, + standardHeaders: true, + legacyHeaders: false, + // We intentionally allow `app.set("trust proxy", true)` for deployments with multiple proxy layers. + // express-rate-limit warns (and can throw) in that configuration; we accept the risk in favor of + // correct client IP handling and rely on deployment-level network controls. + validate: { + trustProxy: false, + }, + keyGenerator: (req) => getClientIp(req), }); -// CSRF Protection Middleware -// Generates a unique client ID based on IP and User-Agent for token association -const getClientId = (req: express.Request): string => { - const ip = req.ip || req.connection.remoteAddress || "unknown"; - const userAgent = req.headers["user-agent"] || "unknown"; - const clientId = `${ip}:${userAgent}`.slice(0, 256); - - // Debug logging for CSRF troubleshooting (issue #38) - if (process.env.DEBUG_CSRF === "true") { - console.log("[CSRF DEBUG] getClientId", { - method: req.method, - path: req.path, - ip, - remoteAddress: req.connection.remoteAddress, - "x-forwarded-for": req.headers["x-forwarded-for"], - "x-real-ip": req.headers["x-real-ip"], - userAgent: userAgent.slice(0, 100), - clientIdPreview: clientId.slice(0, 60) + "...", - trustProxySetting: req.app.get("trust proxy"), - }); - } - - return clientId; -}; - -// Rate limiter specifically for CSRF token generation to prevent store exhaustion -const csrfRateLimit = new Map(); -const CSRF_RATE_LIMIT_WINDOW = 60 * 1000; // 1 minute -const CSRF_MAX_REQUESTS = (() => { - const parsed = Number(process.env.CSRF_MAX_REQUESTS); - if (!Number.isFinite(parsed) || parsed <= 0) { - return 60; // 1 per second average - } - return parsed; -})(); - -// CSRF token endpoint - clients should call this to get a token -app.get("/csrf-token", (req, res) => { - const ip = req.ip || req.connection.remoteAddress || "unknown"; - const now = Date.now(); - const clientLimit = csrfRateLimit.get(ip); - - if (clientLimit && now < clientLimit.resetTime) { - if (clientLimit.count >= CSRF_MAX_REQUESTS) { - return res.status(429).json({ - error: "Rate limit exceeded", - message: "Too many CSRF token requests", - }); - } - clientLimit.count++; - } else { - csrfRateLimit.set(ip, { count: 1, resetTime: now + CSRF_RATE_LIMIT_WINDOW }); - } - - // Cleanup old rate limit entries occasionally - if (Math.random() < 0.01) { - for (const [key, data] of csrfRateLimit.entries()) { - if (now > data.resetTime) csrfRateLimit.delete(key); - } - } +app.use(generalRateLimiter); - const clientId = getClientId(req); - const token = createCsrfToken(clientId); +const apiApp = express(); +app.use(config.apiBasePath, apiApp); - res.json({ - token, - header: getCsrfTokenHeader() - }); +registerCsrfProtection({ + app: apiApp, + isAllowedOrigin, + maxRequestsPerWindow: config.csrfMaxRequests, + enableDebugLogging: process.env.DEBUG_CSRF === "true", }); -// CSRF validation middleware for state-changing requests -const csrfProtectionMiddleware = ( - req: express.Request, - res: express.Response, - next: express.NextFunction -) => { - // Skip CSRF validation for safe methods (GET, HEAD, OPTIONS) - // Note: /csrf-token is a GET endpoint, so it's automatically exempt - const safeMethods = ["GET", "HEAD", "OPTIONS"]; - if (safeMethods.includes(req.method)) { - return next(); - } - - // Origin/Referer check for defense in depth - const origin = req.headers["origin"]; - const referer = req.headers["referer"]; - - // If Origin is present, it must match allowed origins - const originValue = Array.isArray(origin) ? origin[0] : origin; - const refererValue = Array.isArray(referer) ? referer[0] : referer; - - if (originValue) { - if (!allowedOrigins.includes(originValue)) { - return res.status(403).json({ - error: "CSRF origin mismatch", - message: "Origin not allowed", - }); - } - } else if (refererValue) { - // If no Origin but Referer exists, validate its *origin* (avoid prefix bypass) - const refererOrigin = getOriginFromReferer(refererValue); - if (!refererOrigin || !allowedOrigins.includes(refererOrigin)) { - return res.status(403).json({ - error: "CSRF referer mismatch", - message: "Referer not allowed", - }); - } - } - // Note: If neither Origin nor Referer is present, we proceed to token check. - // Some legitimate clients/proxies might strip these, so we don't block strictly on their absence, - // but relying on the token is the primary defense. - - const clientId = getClientId(req); - const headerName = getCsrfTokenHeader(); - const tokenHeader = req.headers[headerName]; - const token = Array.isArray(tokenHeader) ? tokenHeader[0] : tokenHeader; - - if (!token) { - return res.status(403).json({ - error: "CSRF token missing", - message: `Missing ${headerName} header`, - }); - } - - if (!validateCsrfToken(clientId, token)) { - return res.status(403).json({ - error: "CSRF token invalid", - message: "Invalid or expired CSRF token. Please refresh and try again.", - }); - } - - next(); -}; - -// Apply CSRF protection to all routes -app.use(csrfProtectionMiddleware); +// Authentication routes (no CSRF required, uses JWT) +apiApp.use("/auth", authRouter); +// Files field can contain arbitrary file metadata, so we use unknown and validate structure const filesFieldSchema = z - .union([z.record(z.string(), z.any()), z.null()]) + .union([z.record(z.string(), z.unknown()), z.null()]) .optional() .transform((value) => (value === null ? undefined : value)); @@ -508,51 +385,71 @@ const drawingCreateSchema = drawingBaseSchema } ); -const drawingUpdateSchema = drawingBaseSchema +const drawingUpdateSchemaBase = drawingBaseSchema .extend({ elements: elementSchema.array().optional(), appState: appStateSchema.optional(), files: filesFieldSchema, - }) - .refine( - (data) => { - try { - const sanitizedData = { ...data }; - if (data.elements !== undefined || data.appState !== undefined) { - const fullData = { - elements: Array.isArray(data.elements) ? data.elements : [], - appState: - typeof data.appState === "object" && data.appState !== null - ? data.appState - : {}, - files: data.files || {}, - preview: data.preview, - name: data.name, - collectionId: data.collectionId, - }; - const sanitized = sanitizeDrawingData(fullData); - sanitizedData.elements = sanitized.elements; - sanitizedData.appState = sanitized.appState; - if (data.files !== undefined) sanitizedData.files = sanitized.files; - if (data.preview !== undefined) - sanitizedData.preview = sanitized.preview; - Object.assign(data, sanitizedData); - } - return true; - } catch (error) { - console.error("Sanitization failed:", error); - if ( - data.elements === undefined && - data.appState === undefined && - (data.name !== undefined || - data.preview !== undefined || - data.collectionId !== undefined) - ) { - return true; - } - return false; - } - }, + version: z.number().int().positive().optional(), + }); + +export const sanitizeDrawingUpdateData = ( + data: { + elements?: unknown[]; + appState?: Record; + files?: Record; + preview?: string | null; + name?: string; + collectionId?: string | null; + } +): boolean => { + const hasSceneFields = + data.elements !== undefined || + data.appState !== undefined || + data.files !== undefined; + const hasPreviewField = data.preview !== undefined; + const needsSanitization = hasSceneFields || hasPreviewField; + + try { + const sanitizedData = { ...data }; + if (hasSceneFields) { + const fullData = { + elements: Array.isArray(data.elements) ? data.elements : [], + appState: + typeof data.appState === "object" && data.appState !== null + ? data.appState + : {}, + files: data.files || {}, + preview: data.preview, + name: data.name, + collectionId: data.collectionId, + }; + const sanitized = sanitizeDrawingData(fullData); + if (data.elements !== undefined) sanitizedData.elements = sanitized.elements; + if (data.appState !== undefined) sanitizedData.appState = sanitized.appState; + if (data.files !== undefined) sanitizedData.files = sanitized.files; + if (data.preview !== undefined) sanitizedData.preview = sanitized.preview; + Object.assign(data, sanitizedData); + } else if (hasPreviewField && typeof data.preview === "string") { + // Preview-only updates must not inject default scene fields. + data.preview = sanitizePreview(data.preview); + Object.assign(data, { ...data, preview: data.preview }); + } else if (hasPreviewField && data.preview === null) { + // Explicitly allow clearing preview without touching scene data. + Object.assign(data, sanitizedData); + } + return true; + } catch (error) { + console.error("Sanitization failed:", error); + if (!needsSanitization) { + return true; + } + return false; + } +}; + +const drawingUpdateSchema = drawingUpdateSchemaBase.refine( + (data) => sanitizeDrawingUpdateData(data as any), { message: "Invalid or malicious drawing data detected", } @@ -562,12 +459,23 @@ const respondWithValidationErrors = ( res: express.Response, issues: z.ZodIssue[] ) => { - res.status(400).json({ - error: "Invalid drawing payload", - details: issues, - }); + // In production, don't expose validation details + if (config.nodeEnv === "production") { + res.status(400).json({ + error: "Validation error", + message: "Invalid request data", + }); + } else { + res.status(400).json({ + error: "Invalid drawing payload", + details: issues, + }); + } }; +// Collection name validation schema +const collectionNameSchema = z.string().trim().min(1).max(100); + const validateSqliteHeader = (filePath: string): boolean => { try { const buffer = Buffer.alloc(16); @@ -653,655 +561,84 @@ const removeFileIfExists = async (filePath?: string) => { } }; -interface User { - id: string; - name: string; - initials: string; - color: string; - socketId: string; - isActive: boolean; -} - -const roomUsers = new Map(); - -io.on("connection", (socket) => { - socket.on( - "join-room", - ({ - drawingId, - user, - }: { - drawingId: string; - user: Omit; - }) => { - const roomId = `drawing_${drawingId}`; - socket.join(roomId); - - const newUser: User = { ...user, socketId: socket.id, isActive: true }; - - const currentUsers = roomUsers.get(roomId) || []; - const filteredUsers = currentUsers.filter((u) => u.id !== user.id); - filteredUsers.push(newUser); - roomUsers.set(roomId, filteredUsers); - - io.to(roomId).emit("presence-update", filteredUsers); - } - ); - - socket.on("cursor-move", (data) => { - const roomId = `drawing_${data.drawingId}`; - socket.volatile.to(roomId).emit("cursor-move", data); - }); - - socket.on("element-update", (data) => { - const roomId = `drawing_${data.drawingId}`; - socket.to(roomId).emit("element-update", data); - }); - - socket.on( - "user-activity", - ({ drawingId, isActive }: { drawingId: string; isActive: boolean }) => { - const roomId = `drawing_${drawingId}`; - const users = roomUsers.get(roomId); - if (users) { - const user = users.find((u) => u.socketId === socket.id); - if (user) { - user.isActive = isActive; - io.to(roomId).emit("presence-update", users); - } - } - } - ); - - socket.on("disconnect", () => { - roomUsers.forEach((users, roomId) => { - const index = users.findIndex((u) => u.socketId === socket.id); - if (index !== -1) { - users.splice(index, 1); - roomUsers.set(roomId, users); - io.to(roomId).emit("presence-update", users); - } - }); - }); +registerSocketHandlers({ + io, + prisma, + authModeService, + jwtSecret: config.jwtSecret, + collabSessionManager, }); -app.get("/health", (req, res) => { +apiApp.get("/health", (req, res) => { res.status(200).json({ status: "ok" }); }); -app.get("/drawings", async (req, res) => { - try { - const { search, collectionId, includeData } = req.query; - const where: any = {}; - const searchTerm = - typeof search === "string" && search.trim().length > 0 - ? search.trim() - : undefined; - - if (searchTerm) { - where.name = { contains: searchTerm }; - } - - let collectionFilterKey = "default"; - if (collectionId === "null") { - where.collectionId = null; - collectionFilterKey = "null"; - } else if (collectionId) { - const normalizedCollectionId = String(collectionId); - where.collectionId = normalizedCollectionId; - collectionFilterKey = `id:${normalizedCollectionId}`; - } else { - where.OR = [{ collectionId: { not: "trash" } }, { collectionId: null }]; - } - - const shouldIncludeData = - typeof includeData === "string" - ? includeData.toLowerCase() === "true" || includeData === "1" - : false; - - const cacheKey = buildDrawingsCacheKey({ - searchTerm: searchTerm ?? "", - collectionFilter: collectionFilterKey, - includeData: shouldIncludeData, - }); - - const cachedBody = getCachedDrawingsBody(cacheKey); - if (cachedBody) { - res.setHeader("X-Cache", "HIT"); - res.setHeader("Content-Type", "application/json"); - return res.send(cachedBody); - } - - const summarySelect: Prisma.DrawingSelect = { - id: true, - name: true, - collectionId: true, - preview: true, - version: true, - createdAt: true, - updatedAt: true, - }; - - const queryOptions: Prisma.DrawingFindManyArgs = { - where, - orderBy: { updatedAt: "desc" }, - }; - - if (!shouldIncludeData) { - queryOptions.select = summarySelect; - } - - const drawings = await prisma.drawing.findMany(queryOptions); - - let responsePayload: any = drawings; - - if (shouldIncludeData) { - responsePayload = drawings.map((d: any) => ({ - ...d, - elements: parseJsonField(d.elements, []), - appState: parseJsonField(d.appState, {}), - files: parseJsonField(d.files, {}), - })); - } - - const body = cacheDrawingsResponse(cacheKey, responsePayload); - res.setHeader("X-Cache", "MISS"); - res.setHeader("Content-Type", "application/json"); - return res.send(body); - } catch (error) { - console.error(error); - res.status(500).json({ error: "Failed to fetch drawings" }); - } -}); - -app.get("/drawings/:id", async (req, res) => { - try { - const { id } = req.params; - console.log("[API] Fetching drawing", { id }); - const drawing = await prisma.drawing.findUnique({ where: { id } }); - - if (!drawing) { - console.warn("[API] Drawing not found", { id }); - return res.status(404).json({ error: "Drawing not found" }); - } - - res.json({ - ...drawing, - elements: JSON.parse(drawing.elements), - appState: JSON.parse(drawing.appState), - files: JSON.parse(drawing.files || "{}"), - }); - } catch (error) { - res.status(500).json({ error: "Failed to fetch drawing" }); - } -}); - -app.post("/drawings", async (req, res) => { - try { - const isImportedDrawing = req.headers["x-imported-file"] === "true"; - - if (isImportedDrawing && !validateImportedDrawing(req.body)) { - return res.status(400).json({ - error: "Invalid imported drawing file", - message: - "The imported file contains potentially malicious content or invalid structure", - }); - } - - const parsed = drawingCreateSchema.safeParse(req.body); - if (!parsed.success) { - return respondWithValidationErrors(res, parsed.error.issues); - } - - const payload = parsed.data; - const drawingName = payload.name ?? "Untitled Drawing"; - const targetCollectionId = - payload.collectionId === undefined ? null : payload.collectionId; - - const newDrawing = await prisma.drawing.create({ - data: { - name: drawingName, - elements: JSON.stringify(payload.elements), - appState: JSON.stringify(payload.appState), - collectionId: targetCollectionId, - preview: payload.preview ?? null, - files: JSON.stringify(payload.files ?? {}), - }, - }); - invalidateDrawingsCache(); - - res.json({ - ...newDrawing, - elements: JSON.parse(newDrawing.elements), - appState: JSON.parse(newDrawing.appState), - files: JSON.parse(newDrawing.files || "{}"), - }); - } catch (error) { - console.error("Failed to create drawing:", error); - res.status(500).json({ error: "Failed to create drawing" }); - } -}); - -app.put("/drawings/:id", async (req, res) => { - try { - const { id } = req.params; - - const parsed = drawingUpdateSchema.safeParse(req.body); - if (!parsed.success) { - console.error("[API] Validation failed", { - id, - errorCount: parsed.error.issues.length, - errors: parsed.error.issues.map((issue) => ({ - path: issue.path, - message: issue.message, - received: - issue.path.length > 0 ? req.body?.[issue.path.join(".")] : "root", - })), - }); - return respondWithValidationErrors(res, parsed.error.issues); - } - - const payload = parsed.data; - - const data: any = { - version: { increment: 1 }, - }; - - if (payload.name !== undefined) data.name = payload.name; - if (payload.elements !== undefined) - data.elements = JSON.stringify(payload.elements); - if (payload.appState !== undefined) - data.appState = JSON.stringify(payload.appState); - if (payload.files !== undefined) data.files = JSON.stringify(payload.files); - if (payload.collectionId !== undefined) - data.collectionId = payload.collectionId; - if (payload.preview !== undefined) data.preview = payload.preview; - - const updatedDrawing = await prisma.drawing.update({ - where: { id }, - data, - }); - invalidateDrawingsCache(); - - res.json({ - ...updatedDrawing, - elements: JSON.parse(updatedDrawing.elements), - appState: JSON.parse(updatedDrawing.appState), - files: JSON.parse(updatedDrawing.files || "{}"), - }); - } catch (error) { - console.error("[CRITICAL] Update failed:", error); - res.status(500).json({ error: "Failed to update drawing" }); - } -}); - -app.delete("/drawings/:id", async (req, res) => { - try { - const { id } = req.params; - await prisma.drawing.delete({ where: { id } }); - invalidateDrawingsCache(); - res.json({ success: true }); - } catch (error) { - res.status(500).json({ error: "Failed to delete drawing" }); - } -}); - -app.post("/drawings/:id/duplicate", async (req, res) => { - try { - const { id } = req.params; - const original = await prisma.drawing.findUnique({ where: { id } }); - - if (!original) { - return res.status(404).json({ error: "Original drawing not found" }); - } - - const newDrawing = await prisma.drawing.create({ - data: { - name: `${original.name} (Copy)`, - elements: original.elements, - appState: original.appState, - files: original.files, - collectionId: original.collectionId, - version: 1, - }, - }); - invalidateDrawingsCache(); - - res.json({ - ...newDrawing, - elements: JSON.parse(newDrawing.elements), - appState: JSON.parse(newDrawing.appState), - files: JSON.parse(newDrawing.files || "{}"), - }); - } catch (error) { - res.status(500).json({ error: "Failed to duplicate drawing" }); - } -}); - -app.get("/collections", async (req, res) => { - try { - const collections = await prisma.collection.findMany({ - orderBy: { createdAt: "desc" }, - }); - res.json(collections); - } catch (error) { - console.error(error); - res.status(500).json({ error: "Failed to fetch collections" }); - } -}); - -app.post("/collections", async (req, res) => { - try { - const { name } = req.body; - const newCollection = await prisma.collection.create({ - data: { name }, - }); - res.json(newCollection); - } catch (error) { - res.status(500).json({ error: "Failed to create collection" }); - } -}); - -app.put("/collections/:id", async (req, res) => { - try { - const { id } = req.params; - const { name } = req.body; - const updatedCollection = await prisma.collection.update({ - where: { id }, - data: { name }, - }); - res.json(updatedCollection); - } catch (error) { - res.status(500).json({ error: "Failed to update collection" }); - } -}); - -app.delete("/collections/:id", async (req, res) => { - try { - const { id } = req.params; - await prisma.$transaction([ - prisma.drawing.updateMany({ - where: { collectionId: id }, - data: { collectionId: null }, - }), - prisma.collection.delete({ - where: { id }, - }), - ]); - invalidateDrawingsCache(); - - res.json({ success: true }); - } catch (error) { - res.status(500).json({ error: "Failed to delete collection" }); - } -}); - -app.get("/library", async (req, res) => { - try { - const library = await prisma.library.findUnique({ - where: { id: "default" }, - }); - - if (!library) { - return res.json({ items: [] }); - } - - res.json({ - items: JSON.parse(library.items), - }); - } catch (error) { - console.error("Failed to fetch library:", error); - res.status(500).json({ error: "Failed to fetch library" }); - } -}); - -app.put("/library", async (req, res) => { - try { - const { items } = req.body; - - if (!Array.isArray(items)) { - return res.status(400).json({ error: "Items must be an array" }); - } - - const library = await prisma.library.upsert({ - where: { id: "default" }, - update: { - items: JSON.stringify(items), - }, - create: { - id: "default", - items: JSON.stringify(items), - }, - }); - - res.json({ - items: JSON.parse(library.items), - }); - } catch (error) { - console.error("Failed to update library:", error); - res.status(500).json({ error: "Failed to update library" }); - } -}); - -app.get("/export", async (req, res) => { - try { - const formatParam = - typeof req.query.format === "string" - ? req.query.format.toLowerCase() - : undefined; - const extension = formatParam === "db" ? "db" : "sqlite"; - const dbPath = getResolvedDbPath(); - - try { - await fsPromises.access(dbPath); - } catch { - return res.status(404).json({ error: "Database file not found" }); - } - - res.setHeader("Content-Type", "application/octet-stream"); - res.setHeader( - "Content-Disposition", - `attachment; filename="excalidash-db-${new Date().toISOString().split("T")[0] - }.${extension}"` - ); +// Health check endpoint doesn't require auth - const fileStream = fs.createReadStream(dbPath); - fileStream.pipe(res); - } catch (error) { - console.error(error); - res.status(500).json({ error: "Failed to export database" }); - } +registerDashboardRoutes(apiApp, { + prisma, + authModeService, + requireAuth, + asyncHandler, + parseJsonField, + sanitizeText, + validateImportedDrawing, + drawingCreateSchema, + drawingUpdateSchema, + respondWithValidationErrors, + collectionNameSchema, + ensureTrashCollection, + invalidateDrawingsCache, + buildDrawingsCacheKey, + getCachedDrawingsBody, + cacheDrawingsResponse, + collabSessionManager, + MAX_PAGE_SIZE, + config, + logAuditEvent, }); -app.get("/export/json", async (req, res) => { - try { - const drawings = await prisma.drawing.findMany({ - include: { - collection: true, - }, - }); - - res.setHeader("Content-Type", "application/zip"); - res.setHeader( - "Content-Disposition", - `attachment; filename="excalidraw-drawings-${new Date().toISOString().split("T")[0] - }.zip"` - ); - - const archive = archiver("zip", { zlib: { level: 9 } }); - - archive.on("error", (err) => { - console.error("Archive error:", err); - res.status(500).json({ error: "Failed to create archive" }); - }); - - archive.pipe(res); - - const drawingsByCollection: { [key: string]: any[] } = {}; - - drawings.forEach((drawing: any) => { - const collectionName = drawing.collection?.name || "Unorganized"; - if (!drawingsByCollection[collectionName]) { - drawingsByCollection[collectionName] = []; - } - - const drawingData = { - elements: JSON.parse(drawing.elements), - appState: JSON.parse(drawing.appState), - files: JSON.parse(drawing.files || "{}"), - }; - - drawingsByCollection[collectionName].push({ - name: drawing.name, - data: drawingData, - }); - }); - - Object.entries(drawingsByCollection).forEach( - ([collectionName, collectionDrawings]) => { - const folderName = collectionName.replace(/[<>:"/\\|?*]/g, "_"); - collectionDrawings.forEach((drawing, index) => { - const fileName = `${drawing.name.replace( - /[<>:"/\\|?*]/g, - "_" - )}.excalidraw`; - const filePath = `${folderName}/${fileName}`; - - archive.append(JSON.stringify(drawing.data, null, 2), { - name: filePath, - }); - }); - } - ); - - const readmeContent = `ExcaliDash Export - -This archive contains your ExcaliDash drawings organized by collection folders. - -Structure: -- Each collection has its own folder -- Each drawing is saved as a .excalidraw file -- Files can be imported back into ExcaliDash - -Export Date: ${new Date().toISOString()} -Total Collections: ${Object.keys(drawingsByCollection).length} -Total Drawings: ${drawings.length} - -Collections: -${Object.entries(drawingsByCollection) - .map(([name, drawings]) => `- ${name}: ${drawings.length} drawings`) - .join("\n")} -`; - - archive.append(readmeContent, { name: "README.txt" }); - - await archive.finalize(); - } catch (error) { - console.error(error); - res.status(500).json({ error: "Failed to export drawings" }); - } +registerImportExportRoutes({ + app: apiApp, + prisma, + requireAuth, + asyncHandler, + upload, + uploadDir, + backendRoot, + getBackendVersion, + parseJsonField, + sanitizeText, + validateImportedDrawing, + ensureTrashCollection, + invalidateDrawingsCache, + removeFileIfExists, + verifyDatabaseIntegrityAsync, + MAX_IMPORT_ARCHIVE_ENTRIES, + MAX_IMPORT_COLLECTIONS, + MAX_IMPORT_DRAWINGS, + MAX_IMPORT_MANIFEST_BYTES, + MAX_IMPORT_DRAWING_BYTES, + MAX_IMPORT_TOTAL_EXTRACTED_BYTES, }); -app.post("/import/sqlite/verify", upload.single("db"), async (req, res) => { - try { - if (!req.file) { - return res.status(400).json({ error: "No file uploaded" }); - } - - const stagedPath = req.file.path; - const isValid = await verifyDatabaseIntegrityAsync(stagedPath); - await removeFileIfExists(stagedPath); - - if (!isValid) { - return res.status(400).json({ error: "Invalid database format" }); - } +// Error handler middleware (must be last) +app.use(errorHandler); - res.json({ valid: true, message: "Database file is valid" }); - } catch (error) { - console.error(error); - if (req.file) { - await removeFileIfExists(req.file.path); - } - res.status(500).json({ error: "Failed to verify database file" }); - } -}); +export { app, httpServer }; -app.post("/import/sqlite", upload.single("db"), async (req, res) => { - try { - if (!req.file) { - return res.status(400).json({ error: "No file uploaded" }); - } +const isMain = + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + typeof require !== "undefined" && require.main === module; - const originalPath = req.file.path; - const stagedPath = path.join( - uploadDir, - `temp-${Date.now()}-${Math.random().toString(36).slice(2)}.db` +if (isMain) { + httpServer.listen(PORT, async () => { + await initializeUploadDir(); + console.log(`Server running on port ${PORT}`); + console.log(`Environment: ${config.nodeEnv}`); + console.log(`Frontend URL: ${config.frontendUrl}`); + console.log( + `API endpoints: ${config.apiBasePath === "/" ? "/" : `${config.apiBasePath}/`}*` ); - - try { - await moveFile(originalPath, stagedPath); - } catch (error) { - console.error("Failed to stage uploaded database", error); - await removeFileIfExists(originalPath); - await removeFileIfExists(stagedPath); - return res.status(500).json({ error: "Failed to stage uploaded file" }); - } - - const isValid = await verifyDatabaseIntegrityAsync(stagedPath); - if (!isValid) { - await removeFileIfExists(stagedPath); - return res - .status(400) - .json({ error: "Uploaded database failed integrity check" }); - } - - const dbPath = getResolvedDbPath(); - const backupPath = `${dbPath}.backup`; - - try { - try { - await fsPromises.access(dbPath); - await fsPromises.copyFile(dbPath, backupPath); - } catch { } - - await moveFile(stagedPath, dbPath); - } catch (error) { - console.error("Failed to replace database", error); - await removeFileIfExists(stagedPath); - return res.status(500).json({ error: "Failed to replace database" }); - } - - await prisma.$disconnect(); - invalidateDrawingsCache(); - - res.json({ success: true, message: "Database imported successfully" }); - } catch (error) { - console.error(error); - if (req.file) { - await removeFileIfExists(req.file.path); - } - res.status(500).json({ error: "Failed to import database" }); - } -}); - -const ensureTrashCollection = async () => { - try { - const trash = await prisma.collection.findUnique({ - where: { id: "trash" }, - }); - if (!trash) { - await prisma.collection.create({ - data: { id: "trash", name: "Trash" }, - }); - console.log("Created Trash collection"); - } - } catch (error) { - console.error("Failed to ensure Trash collection:", error); - } -}; - -httpServer.listen(PORT, async () => { - await initializeUploadDir(); - await ensureTrashCollection(); - console.log(`Server running on port ${PORT}`); -}); + }); +} diff --git a/backend/src/middleware/auth.test.ts b/backend/src/middleware/auth.test.ts new file mode 100644 index 00000000..4a9a5927 --- /dev/null +++ b/backend/src/middleware/auth.test.ts @@ -0,0 +1,220 @@ +import type { NextFunction, Request, Response } from "express"; +import jwt from "jsonwebtoken"; +import { describe, expect, it, vi } from "vitest"; +import { config } from "../config"; +import { createAuthMiddleware } from "./auth"; + +const createRequest = (overrides?: Partial): Request => + ({ + method: "GET", + originalUrl: "/drawings", + url: "/drawings", + headers: {}, + ...overrides, + }) as Request; + +const createResponse = (): Response => + ({ + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + }) as unknown as Response; + +const createDeps = () => { + const prisma = { + user: { + findUnique: vi.fn(), + }, + } as any; + + const authModeService = { + getAuthEnabled: vi.fn(), + getBootstrapActingUser: vi.fn(), + } as any; + + return { prisma, authModeService }; +}; + +const makeAccessToken = (payload?: { userId?: string; email?: string; impersonatorId?: string }) => + jwt.sign( + { + userId: payload?.userId ?? "user-1", + email: payload?.email ?? "user-1@test.local", + type: "access", + impersonatorId: payload?.impersonatorId, + }, + config.jwtSecret + ); + +const makeRefreshToken = () => + jwt.sign( + { + userId: "user-1", + email: "user-1@test.local", + type: "refresh", + }, + config.jwtSecret + ); + +describe("auth middleware", () => { + it("treats requests as bootstrap user when auth is disabled", async () => { + const { prisma, authModeService } = createDeps(); + authModeService.getAuthEnabled.mockResolvedValue(false); + authModeService.getBootstrapActingUser.mockResolvedValue({ + id: "bootstrap-admin", + username: null, + email: "bootstrap@excalidash.local", + name: "Bootstrap Admin", + role: "ADMIN", + mustResetPassword: true, + }); + const { requireAuth } = createAuthMiddleware({ prisma, authModeService }); + + const req = createRequest(); + const res = createResponse(); + const next = vi.fn() as NextFunction; + + await requireAuth(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(req.user).toMatchObject({ + id: "bootstrap-admin", + role: "ADMIN", + }); + expect(prisma.user.findUnique).not.toHaveBeenCalled(); + }); + + it("returns 401 when token is missing and auth is enabled", async () => { + const { prisma, authModeService } = createDeps(); + authModeService.getAuthEnabled.mockResolvedValue(true); + const { requireAuth } = createAuthMiddleware({ prisma, authModeService }); + + const req = createRequest(); + const res = createResponse(); + const next = vi.fn() as NextFunction; + + await requireAuth(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ message: "Authentication token required" }) + ); + expect(next).not.toHaveBeenCalled(); + }); + + it("rejects non-access JWT payloads", async () => { + const { prisma, authModeService } = createDeps(); + authModeService.getAuthEnabled.mockResolvedValue(true); + const { requireAuth } = createAuthMiddleware({ prisma, authModeService }); + + const req = createRequest({ + headers: { + authorization: `Bearer ${makeRefreshToken()}`, + }, + }); + const res = createResponse(); + const next = vi.fn() as NextFunction; + + await requireAuth(req, res, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ message: "Invalid or expired token" }) + ); + expect(next).not.toHaveBeenCalled(); + }); + + it("attaches active user for valid access token", async () => { + const { prisma, authModeService } = createDeps(); + authModeService.getAuthEnabled.mockResolvedValue(true); + prisma.user.findUnique.mockResolvedValue({ + id: "user-1", + username: "user1", + email: "user-1@test.local", + name: "User One", + role: "USER", + mustResetPassword: false, + isActive: true, + }); + const { requireAuth } = createAuthMiddleware({ prisma, authModeService }); + + const req = createRequest({ + headers: { + authorization: `Bearer ${makeAccessToken({ impersonatorId: "admin-1" })}`, + }, + }); + const res = createResponse(); + const next = vi.fn() as NextFunction; + + await requireAuth(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(req.user).toMatchObject({ + id: "user-1", + email: "user-1@test.local", + impersonatorId: "admin-1", + }); + }); + + it("blocks non-auth routes when password reset is required", async () => { + const { prisma, authModeService } = createDeps(); + authModeService.getAuthEnabled.mockResolvedValue(true); + prisma.user.findUnique.mockResolvedValue({ + id: "user-1", + username: "user1", + email: "user-1@test.local", + name: "User One", + role: "USER", + mustResetPassword: true, + isActive: true, + }); + const { requireAuth } = createAuthMiddleware({ prisma, authModeService }); + + const req = createRequest({ + method: "GET", + originalUrl: "/drawings", + headers: { + authorization: `Bearer ${makeAccessToken()}`, + }, + }); + const res = createResponse(); + const next = vi.fn() as NextFunction; + + await requireAuth(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ code: "MUST_RESET_PASSWORD" }) + ); + expect(next).not.toHaveBeenCalled(); + }); + + it("allows /api/auth/me when password reset is required", async () => { + const { prisma, authModeService } = createDeps(); + authModeService.getAuthEnabled.mockResolvedValue(true); + prisma.user.findUnique.mockResolvedValue({ + id: "user-1", + username: "user1", + email: "user-1@test.local", + name: "User One", + role: "USER", + mustResetPassword: true, + isActive: true, + }); + const { requireAuth } = createAuthMiddleware({ prisma, authModeService }); + + const req = createRequest({ + method: "GET", + originalUrl: "/api/auth/me?include=roles", + headers: { + authorization: `Bearer ${makeAccessToken()}`, + }, + }); + const res = createResponse(); + const next = vi.fn() as NextFunction; + + await requireAuth(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(res.status).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts new file mode 100644 index 00000000..df16c743 --- /dev/null +++ b/backend/src/middleware/auth.ts @@ -0,0 +1,277 @@ +import { Request, Response, NextFunction } from "express"; +import jwt from "jsonwebtoken"; +import { config } from "../config"; +import { PrismaClient } from "../generated/client"; +import { prisma as defaultPrisma } from "../db/prisma"; +import { createAuthModeService, type AuthModeService } from "../auth/authMode"; +import { ACCESS_TOKEN_COOKIE_NAME, readCookie } from "../auth/cookies"; + +// Extend Express Request type to include user +declare global { + namespace Express { + interface Request { + user?: { + id: string; + username?: string | null; + email: string; + name: string; + role: string; + mustResetPassword?: boolean; + impersonatorId?: string; + }; + } + } +} + +interface JwtPayload { + userId: string; + email: string; + type: "access" | "refresh"; + impersonatorId?: string; +} + +const isJwtPayload = (decoded: unknown): decoded is JwtPayload => { + if (typeof decoded !== "object" || decoded === null) { + return false; + } + const payload = decoded as Record; + const impersonatorOk = + typeof payload.impersonatorId === "undefined" || typeof payload.impersonatorId === "string"; + return ( + typeof payload.userId === "string" && + typeof payload.email === "string" && + (payload.type === "access" || payload.type === "refresh") && + impersonatorOk + ); +}; + +const extractToken = (req: Request): string | null => { + const authHeader = req.headers.authorization; + if (authHeader && typeof authHeader === "string") { + const parts = authHeader.split(" "); + if (parts.length === 2 && parts[0] === "Bearer") { + return parts[1] || null; + } + } + + return readCookie(req, ACCESS_TOKEN_COOKIE_NAME); +}; + +const verifyToken = (token: string): JwtPayload | null => { + try { + const decoded = jwt.verify(token, config.jwtSecret); + if (!isJwtPayload(decoded)) { + return null; + } + if (decoded.type !== "access") { + return null; // Only accept access tokens in middleware + } + return decoded; + } catch { + return null; + } +}; + +const normalizeRequestPath = (req: Request): string => { + const raw = (req.originalUrl || req.url || "").split("?")[0] || ""; + // In some deployments the backend may see a /api prefix. + return raw.replace(/^\/api(?=\/)/, ""); +}; + +const isAllowedWhileMustResetPassword = (req: Request): boolean => { + const path = normalizeRequestPath(req); + + // Permit fetching current user and changing password. + if (req.method === "GET" && path === "/auth/me") return true; + if (req.method === "POST" && path === "/auth/change-password") return true; + if (req.method === "POST" && path === "/auth/must-reset-password") return true; + + return false; +}; + +export type AuthMiddlewareDeps = { + prisma: PrismaClient; + authModeService: AuthModeService; +}; + +export const createAuthMiddleware = ({ + prisma, + authModeService, +}: AuthMiddlewareDeps) => { + const requireAuth = async ( + req: Request, + res: Response, + next: NextFunction + ): Promise => { + // Single-user mode: authentication disabled -> treat all requests as the bootstrap user. + try { + const authEnabled = await authModeService.getAuthEnabled(); + if (!authEnabled) { + const user = await authModeService.getBootstrapActingUser(); + req.user = { + id: user.id, + username: user.username, + email: user.email, + name: user.name, + role: user.role, + mustResetPassword: user.mustResetPassword, + }; + return next(); + } + } catch (error) { + console.error("Error reading auth mode:", error); + res.status(500).json({ + error: "Internal server error", + message: "Failed to read authentication mode", + }); + return; + } + + const token = extractToken(req); + + if (!token) { + res.status(401).json({ + error: "Unauthorized", + message: "Authentication token required", + }); + return; + } + + const payload = verifyToken(token); + + if (!payload) { + res.status(401).json({ + error: "Unauthorized", + message: "Invalid or expired token", + }); + return; + } + + // Verify user still exists and is active + try { + const user = await prisma.user.findUnique({ + where: { id: payload.userId }, + select: { + id: true, + username: true, + email: true, + name: true, + role: true, + mustResetPassword: true, + isActive: true, + }, + }); + + if (!user || !user.isActive) { + res.status(401).json({ + error: "Unauthorized", + message: "User account not found or inactive", + }); + return; + } + + if (user.mustResetPassword && !isAllowedWhileMustResetPassword(req)) { + res.status(403).json({ + error: "Forbidden", + code: "MUST_RESET_PASSWORD", + message: "You must reset your password before using the app", + }); + return; + } + + // Attach user to request + req.user = { + id: user.id, + username: user.username, + email: user.email, + name: user.name, + role: user.role, + mustResetPassword: user.mustResetPassword, + impersonatorId: payload.impersonatorId, + }; + + next(); + } catch (error) { + console.error("Error verifying user:", error); + res.status(500).json({ + error: "Internal server error", + message: "Failed to verify user", + }); + } + }; + + const optionalAuth = async ( + req: Request, + res: Response, + next: NextFunction + ): Promise => { + try { + const authEnabled = await authModeService.getAuthEnabled(); + if (!authEnabled) { + return next(); + } + } catch (error) { + console.error("Error reading auth mode:", error); + return next(); + } + + const token = extractToken(req); + + if (!token) { + return next(); + } + + const payload = verifyToken(token); + + if (!payload) { + return next(); + } + + try { + const user = await prisma.user.findUnique({ + where: { id: payload.userId }, + select: { + id: true, + username: true, + email: true, + name: true, + role: true, + mustResetPassword: true, + isActive: true, + }, + }); + + if (user && user.isActive) { + req.user = { + id: user.id, + username: user.username, + email: user.email, + name: user.name, + role: user.role, + mustResetPassword: user.mustResetPassword, + impersonatorId: payload.impersonatorId, + }; + } + } catch (error) { + // Silently fail for optional auth + console.error("Error in optional auth:", error); + } + + next(); + }; + + return { + requireAuth, + optionalAuth, + }; +}; + +const defaultAuthModeService = createAuthModeService(defaultPrisma); +const defaultAuthMiddleware = createAuthMiddleware({ + prisma: defaultPrisma, + authModeService: defaultAuthModeService, +}); + +export const authModeService = defaultAuthModeService; +export const requireAuth = defaultAuthMiddleware.requireAuth; +export const optionalAuth = defaultAuthMiddleware.optionalAuth; diff --git a/backend/src/middleware/errorHandler.ts b/backend/src/middleware/errorHandler.ts new file mode 100644 index 00000000..c6edf17b --- /dev/null +++ b/backend/src/middleware/errorHandler.ts @@ -0,0 +1,86 @@ +/** + * Error handling middleware + * Sanitizes error messages in production to prevent information leakage + */ +import { Request, Response, NextFunction } from "express"; +import { config } from "../config"; + +export interface AppError extends Error { + statusCode?: number; + isOperational?: boolean; +} + +/** + * Error handler middleware + * Should be added last in the middleware chain + */ +export const errorHandler = ( + err: AppError, + req: Request, + res: Response, + next: NextFunction +): void => { + const statusCode = err.statusCode || 500; + const isDevelopment = config.nodeEnv === "development"; + + // Log full error details server-side + console.error("Error:", { + message: err.message, + stack: err.stack, + statusCode, + path: req.path, + method: req.method, + timestamp: new Date().toISOString(), + }); + + // In production, don't expose internal error details + if (!isDevelopment) { + // Generic error messages for clients + if (statusCode >= 500) { + res.status(statusCode).json({ + error: "Internal server error", + message: "An error occurred while processing your request", + }); + return; + } + + // For client errors (4xx), provide generic message + res.status(statusCode).json({ + error: "Request error", + message: err.isOperational ? err.message : "Invalid request", + }); + return; + } + + // In development, show full error details + res.status(statusCode).json({ + error: err.message, + stack: err.stack, + statusCode, + }); +}; + +/** + * Async error wrapper + * Wraps async route handlers to catch errors + */ +export const asyncHandler = ( + fn: (req: Request, res: Response, next: NextFunction) => Promise +) => { + return (req: Request, res: Response, next: NextFunction): void => { + Promise.resolve(fn(req, res, next)).catch(next); + }; +}; + +/** + * Create an operational error (known error that can be safely shown to client) + */ +export const createError = ( + message: string, + statusCode: number = 400 +): AppError => { + const error: AppError = new Error(message); + error.statusCode = statusCode; + error.isOperational = true; + return error; +}; \ No newline at end of file diff --git a/backend/src/routes/dashboard.ts b/backend/src/routes/dashboard.ts new file mode 100644 index 00000000..82d4c7e4 --- /dev/null +++ b/backend/src/routes/dashboard.ts @@ -0,0 +1,2 @@ +export { registerDashboardRoutes } from "./dashboard/index"; +export type { DashboardRouteDeps } from "./dashboard/index"; diff --git a/backend/src/routes/dashboard/collections.ts b/backend/src/routes/dashboard/collections.ts new file mode 100644 index 00000000..69cdc597 --- /dev/null +++ b/backend/src/routes/dashboard/collections.ts @@ -0,0 +1,146 @@ +import express from "express"; +import { DashboardRouteDeps } from "./types"; +import { getUserTrashCollectionId, isTrashCollectionId } from "./trash"; + +const getRouteIdParam = (value: string | string[] | undefined): string | null => { + if (typeof value === "string" && value.trim().length > 0) return value; + if (Array.isArray(value) && typeof value[0] === "string" && value[0].trim().length > 0) { + return value[0]; + } + return null; +}; + +export const registerCollectionRoutes = ( + app: express.Express, + deps: DashboardRouteDeps +) => { + const { + prisma, + requireAuth, + asyncHandler, + collectionNameSchema, + sanitizeText, + ensureTrashCollection, + invalidateDrawingsCache, + config, + logAuditEvent, + } = deps; + + app.get("/collections", requireAuth, asyncHandler(async (req, res) => { + if (!req.user) return res.status(401).json({ error: "Unauthorized" }); + const trashCollectionId = getUserTrashCollectionId(req.user.id); + await ensureTrashCollection(prisma, req.user.id); + + const rawCollections = await prisma.collection.findMany({ + where: { userId: req.user.id }, + orderBy: { createdAt: "desc" }, + }); + const hasInternalTrash = rawCollections.some((collection) => collection.id === trashCollectionId); + const collections = rawCollections + .filter((collection) => !(hasInternalTrash && collection.id === "trash")) + .map((collection) => + collection.id === trashCollectionId + ? { ...collection, id: "trash", name: "Trash" } + : collection + ); + return res.json(collections); + })); + + app.post("/collections", requireAuth, asyncHandler(async (req, res) => { + if (!req.user) return res.status(401).json({ error: "Unauthorized" }); + + const parsed = collectionNameSchema.safeParse(req.body.name); + if (!parsed.success) { + return res.status(400).json({ + error: "Validation error", + message: "Collection name must be between 1 and 100 characters", + }); + } + + const sanitizedName = sanitizeText(parsed.data, 100); + const newCollection = await prisma.collection.create({ + data: { name: sanitizedName, userId: req.user.id }, + }); + return res.json(newCollection); + })); + + app.put("/collections/:id", requireAuth, asyncHandler(async (req, res) => { + if (!req.user) return res.status(401).json({ error: "Unauthorized" }); + + const id = getRouteIdParam(req.params.id); + if (!id) return res.status(400).json({ error: "Validation error", message: "Invalid id parameter" }); + if (isTrashCollectionId(id, req.user.id)) { + return res.status(400).json({ + error: "Validation error", + message: "Trash collection cannot be renamed", + }); + } + const existingCollection = await prisma.collection.findFirst({ + where: { id, userId: req.user.id }, + }); + if (!existingCollection) return res.status(404).json({ error: "Collection not found" }); + + const parsed = collectionNameSchema.safeParse(req.body.name); + if (!parsed.success) { + return res.status(400).json({ + error: "Validation error", + message: "Collection name must be between 1 and 100 characters", + }); + } + + const sanitizedName = sanitizeText(parsed.data, 100); + const updateResult = await prisma.collection.updateMany({ + where: { id, userId: req.user.id }, + data: { name: sanitizedName }, + }); + if (updateResult.count === 0) { + return res.status(404).json({ error: "Collection not found" }); + } + const updatedCollection = await prisma.collection.findFirst({ + where: { id, userId: req.user.id }, + }); + if (!updatedCollection) { + return res.status(404).json({ error: "Collection not found" }); + } + return res.json(updatedCollection); + })); + + app.delete("/collections/:id", requireAuth, asyncHandler(async (req, res) => { + if (!req.user) return res.status(401).json({ error: "Unauthorized" }); + + const id = getRouteIdParam(req.params.id); + if (!id) return res.status(400).json({ error: "Validation error", message: "Invalid id parameter" }); + if (isTrashCollectionId(id, req.user.id)) { + return res.status(400).json({ + error: "Validation error", + message: "Trash collection cannot be deleted", + }); + } + const collection = await prisma.collection.findFirst({ + where: { id, userId: req.user.id }, + }); + if (!collection) return res.status(404).json({ error: "Collection not found" }); + + await prisma.$transaction([ + prisma.drawing.updateMany({ + where: { collectionId: id, userId: req.user.id }, + data: { collectionId: null }, + }), + prisma.collection.deleteMany({ where: { id, userId: req.user.id } }), + ]); + invalidateDrawingsCache(); + + if (config.enableAuditLogging) { + await logAuditEvent({ + userId: req.user.id, + action: "collection_deleted", + resource: `collection:${id}`, + ipAddress: req.ip || req.connection.remoteAddress || undefined, + userAgent: req.headers["user-agent"] || undefined, + details: { collectionId: id, collectionName: collection.name }, + }); + } + + return res.json({ success: true }); + })); +}; diff --git a/backend/src/routes/dashboard/drawings.ts b/backend/src/routes/dashboard/drawings.ts new file mode 100644 index 00000000..2177e59a --- /dev/null +++ b/backend/src/routes/dashboard/drawings.ts @@ -0,0 +1,800 @@ +import express from "express"; +import { Prisma } from "../../generated/client"; +import { DashboardRouteDeps, SortDirection, SortField } from "./types"; +import { + getUserTrashCollectionId, + isTrashCollectionId, + toInternalTrashCollectionId, + toPublicTrashCollectionId, +} from "./trash"; +import { + ensureShareLinkForRole, + isAtLeastRole, + isShareRole, + rotateShareLinkForRole, + resolveDrawingAccess, +} from "../../server/drawingAccess"; + +const getRouteIdParam = (value: string | string[] | undefined): string | null => { + if (typeof value === "string" && value.trim().length > 0) return value; + if (Array.isArray(value) && typeof value[0] === "string" && value[0].trim().length > 0) { + return value[0]; + } + return null; +}; + +const getShareTokenFromRequest = (req: express.Request): string | undefined => { + const value = req.headers["x-share-token"]; + if (typeof value === "string" && value.trim().length > 0) { + return value.trim(); + } + if (Array.isArray(value) && typeof value[0] === "string" && value[0].trim().length > 0) { + return value[0].trim(); + } + return undefined; +}; + +const payloadHasOnlySceneFields = (payload: Record): boolean => { + const allowed = new Set(["elements", "appState", "files", "preview", "version"]); + return Object.keys(payload).every((key) => allowed.has(key)); +}; + +export const registerDrawingRoutes = ( + app: express.Express, + deps: DashboardRouteDeps +) => { + const { + prisma, + authModeService, + requireAuth, + asyncHandler, + parseJsonField, + validateImportedDrawing, + drawingCreateSchema, + drawingUpdateSchema, + respondWithValidationErrors, + ensureTrashCollection, + invalidateDrawingsCache, + buildDrawingsCacheKey, + getCachedDrawingsBody, + cacheDrawingsResponse, + collabSessionManager, + MAX_PAGE_SIZE, + config, + logAuditEvent, + } = deps; + + app.get("/drawings", requireAuth, asyncHandler(async (req, res) => { + if (!req.user) { + return res.status(401).json({ error: "Unauthorized" }); + } + + const trashCollectionId = getUserTrashCollectionId(req.user.id); + const { search, collectionId, includeData, limit, offset, sortField, sortDirection } = req.query; + const where: Prisma.DrawingWhereInput = { userId: req.user.id }; + const searchTerm = + typeof search === "string" && search.trim().length > 0 ? search.trim() : undefined; + + if (searchTerm) { + where.name = { contains: searchTerm }; + } + + let collectionFilterKey = "default"; + if (collectionId === "null") { + where.collectionId = null; + collectionFilterKey = "null"; + } else if (collectionId) { + const normalizedCollectionId = String(collectionId); + if (normalizedCollectionId === "trash") { + where.collectionId = { in: [trashCollectionId, "trash"] }; + collectionFilterKey = "trash"; + } else { + const collection = await prisma.collection.findFirst({ + where: { id: normalizedCollectionId, userId: req.user.id }, + }); + if (!collection) { + return res.status(404).json({ error: "Collection not found" }); + } + where.collectionId = normalizedCollectionId; + collectionFilterKey = `id:${normalizedCollectionId}`; + } + } else { + where.OR = [ + { collectionId: { notIn: [trashCollectionId, "trash"] } }, + { collectionId: null }, + ]; + } + + const shouldIncludeData = + typeof includeData === "string" + ? includeData.toLowerCase() === "true" || includeData === "1" + : false; + const parsedSortField: SortField = + sortField === "name" || sortField === "createdAt" || sortField === "updatedAt" + ? sortField + : "updatedAt"; + const parsedSortDirection: SortDirection = + sortDirection === "asc" || sortDirection === "desc" + ? sortDirection + : parsedSortField === "name" + ? "asc" + : "desc"; + + const rawLimit = limit ? Number.parseInt(limit as string, 10) : undefined; + const rawOffset = offset ? Number.parseInt(offset as string, 10) : undefined; + const parsedLimit = + rawLimit !== undefined && Number.isFinite(rawLimit) + ? Math.min(Math.max(rawLimit, 1), MAX_PAGE_SIZE) + : undefined; + const parsedOffset = + rawOffset !== undefined && Number.isFinite(rawOffset) ? Math.max(rawOffset, 0) : undefined; + + const cacheKey = + buildDrawingsCacheKey({ + userId: req.user.id, + searchTerm: searchTerm ?? "", + collectionFilter: collectionFilterKey, + includeData: shouldIncludeData, + sortField: parsedSortField, + sortDirection: parsedSortDirection, + }) + `:${parsedLimit}:${parsedOffset}`; + + const cachedBody = getCachedDrawingsBody(cacheKey); + if (cachedBody) { + res.setHeader("X-Cache", "HIT"); + res.setHeader("Content-Type", "application/json"); + return res.send(cachedBody); + } + + const summarySelect: Prisma.DrawingSelect = { + id: true, + name: true, + collectionId: true, + preview: true, + version: true, + createdAt: true, + updatedAt: true, + }; + + const orderBy: Prisma.DrawingOrderByWithRelationInput = + parsedSortField === "name" + ? { name: parsedSortDirection } + : parsedSortField === "createdAt" + ? { createdAt: parsedSortDirection } + : { updatedAt: parsedSortDirection }; + + const queryOptions: Prisma.DrawingFindManyArgs = { where, orderBy }; + if (parsedLimit !== undefined) queryOptions.take = parsedLimit; + if (parsedOffset !== undefined) queryOptions.skip = parsedOffset; + if (!shouldIncludeData) queryOptions.select = summarySelect; + + const [drawings, totalCount] = await Promise.all([ + prisma.drawing.findMany(queryOptions), + prisma.drawing.count({ where }), + ]); + + let responsePayload: any[] = drawings as any[]; + if (shouldIncludeData) { + responsePayload = (drawings as any[]).map((d: any) => ({ + ...d, + accessRole: "owner", + collectionId: toPublicTrashCollectionId(d.collectionId, req.user!.id), + elements: parseJsonField(d.elements, []), + appState: parseJsonField(d.appState, {}), + files: parseJsonField(d.files, {}), + })); + } else { + responsePayload = (drawings as any[]).map((d: any) => ({ + ...d, + accessRole: "owner", + collectionId: toPublicTrashCollectionId(d.collectionId, req.user!.id), + })); + } + + const finalResponse = { + drawings: responsePayload, + totalCount, + limit: parsedLimit, + offset: parsedOffset, + }; + + const body = cacheDrawingsResponse(cacheKey, finalResponse); + res.setHeader("X-Cache", "MISS"); + res.setHeader("Content-Type", "application/json"); + return res.send(body); + })); + + app.get("/drawings/shared", requireAuth, asyncHandler(async (req, res) => { + if (!req.user) return res.status(401).json({ error: "Unauthorized" }); + + const authEnabled = await authModeService.getAuthEnabled(); + if (!authEnabled) { + return res.status(404).json({ error: "Not found" }); + } + + const { search, includeData, limit, offset, sortField, sortDirection } = req.query; + const searchTerm = + typeof search === "string" && search.trim().length > 0 ? search.trim() : undefined; + + const shouldIncludeData = + typeof includeData === "string" + ? includeData.toLowerCase() === "true" || includeData === "1" + : false; + + const parsedSortField: SortField = + sortField === "name" || sortField === "createdAt" || sortField === "updatedAt" + ? sortField + : "updatedAt"; + const parsedSortDirection: SortDirection = + sortDirection === "asc" || sortDirection === "desc" + ? sortDirection + : parsedSortField === "name" + ? "asc" + : "desc"; + + const rawLimit = limit ? Number.parseInt(limit as string, 10) : undefined; + const rawOffset = offset ? Number.parseInt(offset as string, 10) : undefined; + const parsedLimit = + rawLimit !== undefined && Number.isFinite(rawLimit) + ? Math.min(Math.max(rawLimit, 1), MAX_PAGE_SIZE) + : undefined; + const parsedOffset = + rawOffset !== undefined && Number.isFinite(rawOffset) ? Math.max(rawOffset, 0) : undefined; + + const baseRows = await prisma.$queryRaw>(Prisma.sql` + SELECT + d."id" AS id, + d."name" AS name, + d."collectionId" AS "collectionId", + d."preview" AS preview, + d."version" AS version, + d."createdAt" AS "createdAt", + d."updatedAt" AS "updatedAt", + d."elements" AS elements, + d."appState" AS "appState", + d."files" AS files, + u."id" AS "ownerId", + u."name" AS "ownerName", + u."email" AS "ownerEmail", + MAX(CASE g."role" WHEN 'editor' THEN 2 WHEN 'viewer' THEN 1 ELSE 0 END) AS "roleRank" + FROM "DrawingShareGrant" g + JOIN "Drawing" d ON d."id" = g."drawingId" + JOIN "User" u ON u."id" = d."userId" + WHERE g."userId" = ${req.user.id} + AND d."userId" <> ${req.user.id} + AND g."role" IN ('viewer', 'editor') + ${searchTerm ? Prisma.sql`AND d."name" LIKE ${`%${searchTerm}%`}` : Prisma.empty} + GROUP BY d."id", u."id" + `); + + const sortedRows = [...baseRows].sort((a, b) => { + const direction = parsedSortDirection === "asc" ? 1 : -1; + if (parsedSortField === "name") { + return a.name.localeCompare(b.name) * direction; + } + if (parsedSortField === "createdAt") { + return (new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) * direction; + } + return (new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()) * direction; + }); + + const totalCount = sortedRows.length; + const start = parsedOffset ?? 0; + const end = parsedLimit !== undefined ? start + parsedLimit : undefined; + const pageRows = sortedRows.slice(start, end); + + const payload = pageRows.map((row) => { + const accessRole = Number(row.roleRank) >= 2 ? "editor" : "viewer"; + const base = { + id: row.id, + name: row.name, + preview: row.preview, + version: row.version, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + collectionId: null, + accessRole, + owner: { + id: row.ownerId, + name: row.ownerName, + email: row.ownerEmail, + }, + } as Record; + + if (shouldIncludeData) { + base.elements = parseJsonField(row.elements, []); + base.appState = parseJsonField(row.appState, {}); + base.files = parseJsonField(row.files, {}); + } + + return base; + }); + + return res.json({ + drawings: payload, + totalCount, + limit: parsedLimit, + offset: parsedOffset, + }); + })); + + app.get("/drawings/:id/share-links", requireAuth, asyncHandler(async (req, res) => { + if (!req.user) return res.status(401).json({ error: "Unauthorized" }); + + const authEnabled = await authModeService.getAuthEnabled(); + if (!authEnabled) { + return res.status(404).json({ error: "Not found" }); + } + + const id = getRouteIdParam(req.params.id); + if (!id) { + return res.status(400).json({ error: "Validation error", message: "Invalid id parameter" }); + } + + const drawing = await prisma.drawing.findFirst({ where: { id, userId: req.user.id }, select: { id: true } }); + if (!drawing) { + return res.status(404).json({ error: "Drawing not found" }); + } + + const [viewer, editor] = await Promise.all([ + ensureShareLinkForRole(prisma, id, "viewer"), + ensureShareLinkForRole(prisma, id, "editor"), + ]); + + return res.json({ + drawingId: id, + viewerToken: viewer.token, + editorToken: editor.token, + }); + })); + + app.post("/drawings/:id/share-links/:role/rotate", requireAuth, asyncHandler(async (req, res) => { + if (!req.user) return res.status(401).json({ error: "Unauthorized" }); + + const authEnabled = await authModeService.getAuthEnabled(); + if (!authEnabled) { + return res.status(404).json({ error: "Not found" }); + } + + const id = getRouteIdParam(req.params.id); + if (!id) { + return res.status(400).json({ error: "Validation error", message: "Invalid id parameter" }); + } + + const roleParam = getRouteIdParam(req.params.role); + if (!isShareRole(roleParam)) { + return res.status(400).json({ error: "Validation error", message: "Invalid share role" }); + } + + const drawing = await prisma.drawing.findFirst({ where: { id, userId: req.user.id }, select: { id: true } }); + if (!drawing) { + return res.status(404).json({ error: "Drawing not found" }); + } + + const rotated = await rotateShareLinkForRole(prisma, id, roleParam); + + if (config.enableAuditLogging) { + await logAuditEvent({ + userId: req.user.id, + action: "drawing_share_link_rotated", + resource: `drawing:${id}`, + ipAddress: req.ip || req.connection.remoteAddress || undefined, + userAgent: req.headers["user-agent"] || undefined, + details: { drawingId: id, role: roleParam }, + }); + } + + return res.json({ + role: roleParam, + drawingId: id, + token: rotated.token, + }); + })); + + app.get("/drawings/:id", requireAuth, asyncHandler(async (req, res) => { + if (!req.user) return res.status(401).json({ error: "Unauthorized" }); + + const id = getRouteIdParam(req.params.id); + if (!id) return res.status(400).json({ error: "Validation error", message: "Invalid id parameter" }); + + const access = await resolveDrawingAccess({ + prisma, + drawingId: id, + userId: req.user.id, + shareToken: getShareTokenFromRequest(req), + }); + + if (!access) { + return res.status(404).json({ error: "Drawing not found", message: "Drawing does not exist" }); + } + + if (access.tokenRedeemedRole && config.enableAuditLogging) { + await logAuditEvent({ + userId: req.user.id, + action: "drawing_share_token_redeemed", + resource: `drawing:${id}`, + ipAddress: req.ip || req.connection.remoteAddress || undefined, + userAgent: req.headers["user-agent"] || undefined, + details: { drawingId: id, role: access.tokenRedeemedRole }, + }); + } + + return res.json({ + ...access.drawing, + accessRole: access.role, + collectionId: + access.role === "owner" + ? toPublicTrashCollectionId(access.drawing.collectionId, req.user.id) + : null, + elements: parseJsonField(access.drawing.elements, []), + appState: parseJsonField(access.drawing.appState, {}), + files: parseJsonField(access.drawing.files, {}), + }); + })); + + app.get("/drawings/:id/scene-meta", requireAuth, asyncHandler(async (req, res) => { + if (!req.user) return res.status(401).json({ error: "Unauthorized" }); + const id = getRouteIdParam(req.params.id); + if (!id) return res.status(400).json({ error: "Validation error", message: "Invalid id parameter" }); + + const access = await resolveDrawingAccess({ + prisma, + drawingId: id, + userId: req.user.id, + }); + if (!access) return res.status(404).json({ error: "Drawing not found" }); + + const meta = await collabSessionManager.getMeta(id); + if (!meta) { + return res.status(404).json({ error: "Drawing not found" }); + } + + return res.json({ + drawingId: id, + seq: meta.seq, + dbVersion: meta.dbVersion, + updatedAt: access.drawing.updatedAt, + }); + })); + + app.post("/drawings/:id/flush", requireAuth, asyncHandler(async (req, res) => { + if (!req.user) return res.status(401).json({ error: "Unauthorized" }); + const id = getRouteIdParam(req.params.id); + if (!id) return res.status(400).json({ error: "Validation error", message: "Invalid id parameter" }); + + const access = await resolveDrawingAccess({ + prisma, + drawingId: id, + userId: req.user.id, + }); + if (!access) return res.status(404).json({ error: "Drawing not found" }); + if (!isAtLeastRole(access.role, "editor")) { + return res.status(403).json({ error: "Forbidden", message: "You do not have edit access" }); + } + + await collabSessionManager.flushSession(id); + const meta = await collabSessionManager.getMeta(id); + + return res.json({ + success: true, + drawingId: id, + seq: meta?.seq ?? null, + dbVersion: meta?.dbVersion ?? null, + }); + })); + + app.post("/drawings", requireAuth, asyncHandler(async (req, res) => { + if (!req.user) return res.status(401).json({ error: "Unauthorized" }); + + const isImportedDrawing = req.headers["x-imported-file"] === "true"; + if (isImportedDrawing && !validateImportedDrawing(req.body)) { + return res.status(400).json({ + error: "Invalid imported drawing file", + message: "The imported file contains potentially malicious content or invalid structure", + }); + } + + const parsed = drawingCreateSchema.safeParse(req.body); + if (!parsed.success) { + return respondWithValidationErrors(res, parsed.error.issues); + } + + const payload = parsed.data as { + name?: string; + collectionId?: string | null; + elements: unknown[]; + appState: Record; + preview?: string | null; + files?: Record; + }; + const drawingName = payload.name ?? "Untitled Drawing"; + const targetCollectionIdRaw = payload.collectionId === undefined ? null : payload.collectionId; + const targetCollectionId = + toInternalTrashCollectionId(targetCollectionIdRaw, req.user.id) ?? null; + + if (targetCollectionId && !isTrashCollectionId(targetCollectionId, req.user.id)) { + const collection = await prisma.collection.findFirst({ + where: { id: targetCollectionId, userId: req.user.id }, + }); + if (!collection) return res.status(404).json({ error: "Collection not found" }); + } else if (targetCollectionIdRaw === "trash") { + await ensureTrashCollection(prisma, req.user.id); + } + + const newDrawing = await prisma.drawing.create({ + data: { + name: drawingName, + elements: JSON.stringify(payload.elements), + appState: JSON.stringify(payload.appState), + userId: req.user.id, + collectionId: targetCollectionId, + preview: payload.preview ?? null, + files: JSON.stringify(payload.files ?? {}), + }, + }); + invalidateDrawingsCache(); + + return res.json({ + ...newDrawing, + accessRole: "owner", + collectionId: toPublicTrashCollectionId(newDrawing.collectionId, req.user.id), + elements: parseJsonField(newDrawing.elements, []), + appState: parseJsonField(newDrawing.appState, {}), + files: parseJsonField(newDrawing.files, {}), + }); + })); + + app.put("/drawings/:id", requireAuth, asyncHandler(async (req, res) => { + if (!req.user) return res.status(401).json({ error: "Unauthorized" }); + + const id = getRouteIdParam(req.params.id); + if (!id) return res.status(400).json({ error: "Validation error", message: "Invalid id parameter" }); + + const access = await resolveDrawingAccess({ + prisma, + drawingId: id, + userId: req.user.id, + }); + if (!access) return res.status(404).json({ error: "Drawing not found" }); + + const parsed = drawingUpdateSchema.safeParse(req.body); + if (!parsed.success) { + if (config.nodeEnv === "development") { + console.error("[API] Validation failed", { id, errors: parsed.error.issues }); + } + return respondWithValidationErrors(res, parsed.error.issues); + } + + const payload = parsed.data as { + name?: string; + collectionId?: string | null; + elements?: unknown[]; + appState?: Record; + preview?: string | null; + files?: Record; + version?: number; + }; + + const payloadRecord = payload as unknown as Record; + + if (!isAtLeastRole(access.role, "editor")) { + return res.status(403).json({ error: "Forbidden", message: "You do not have edit access" }); + } + + if (access.role === "editor" && !payloadHasOnlySceneFields(payloadRecord)) { + return res.status(403).json({ error: "Forbidden", message: "Editors can only update scene content" }); + } + + const trashCollectionId = getUserTrashCollectionId(req.user.id); + const isSceneUpdate = + payload.elements !== undefined || + payload.appState !== undefined || + payload.files !== undefined; + const data: Prisma.DrawingUpdateInput = isSceneUpdate + ? { version: { increment: 1 } } + : {}; + + if (payload.name !== undefined) data.name = payload.name; + if (payload.elements !== undefined) data.elements = JSON.stringify(payload.elements); + if (payload.appState !== undefined) data.appState = JSON.stringify(payload.appState); + if (payload.files !== undefined) data.files = JSON.stringify(payload.files); + if (payload.preview !== undefined) data.preview = payload.preview; + + if (payload.collectionId !== undefined) { + if (access.role !== "owner") { + return res.status(403).json({ error: "Forbidden", message: "Only the owner can move drawings" }); + } + if (payload.collectionId === "trash") { + await ensureTrashCollection(prisma, req.user.id); + (data as Prisma.DrawingUncheckedUpdateInput).collectionId = trashCollectionId; + } else if (payload.collectionId) { + const collection = await prisma.collection.findFirst({ + where: { id: payload.collectionId, userId: req.user.id }, + }); + if (!collection) return res.status(404).json({ error: "Collection not found" }); + (data as Prisma.DrawingUncheckedUpdateInput).collectionId = payload.collectionId; + } else { + (data as Prisma.DrawingUncheckedUpdateInput).collectionId = null; + } + } + + const updateWhere: Prisma.DrawingWhereInput = { id }; + if (access.role === "owner") { + updateWhere.userId = req.user.id; + } + if (isSceneUpdate && payload.version !== undefined) { + updateWhere.version = payload.version; + } + + const updateResult = await prisma.drawing.updateMany({ + where: updateWhere, + data, + }); + if (updateResult.count === 0) { + if (isSceneUpdate && payload.version !== undefined) { + const latestDrawing = await prisma.drawing.findFirst({ + where: { id }, + select: { version: true }, + }); + return res.status(409).json({ + error: "Conflict", + code: "VERSION_CONFLICT", + message: "Drawing has changed since this editor state was loaded.", + currentVersion: latestDrawing?.version ?? null, + }); + } + return res.status(404).json({ error: "Drawing not found" }); + } + + const updatedDrawing = await prisma.drawing.findFirst({ + where: { id }, + }); + if (!updatedDrawing) { + return res.status(404).json({ error: "Drawing not found" }); + } + invalidateDrawingsCache(); + + return res.json({ + ...updatedDrawing, + accessRole: access.role, + collectionId: + access.role === "owner" + ? toPublicTrashCollectionId(updatedDrawing.collectionId, req.user.id) + : null, + elements: parseJsonField(updatedDrawing.elements, []), + appState: parseJsonField(updatedDrawing.appState, {}), + files: parseJsonField(updatedDrawing.files, {}), + }); + })); + + app.put("/drawings/:id/preview", requireAuth, asyncHandler(async (req, res) => { + if (!req.user) return res.status(401).json({ error: "Unauthorized" }); + + const id = getRouteIdParam(req.params.id); + if (!id) { + return res.status(400).json({ error: "Validation error", message: "Invalid id parameter" }); + } + + const access = await resolveDrawingAccess({ + prisma, + drawingId: id, + userId: req.user.id, + }); + if (!access) return res.status(404).json({ error: "Drawing not found" }); + if (!isAtLeastRole(access.role, "editor")) { + return res.status(403).json({ error: "Forbidden", message: "You do not have edit access" }); + } + + const parsed = drawingUpdateSchema.safeParse({ preview: req.body?.preview }); + if (!parsed.success) { + if (config.nodeEnv === "development") { + console.error("[API] Preview validation failed", { id, errors: parsed.error.issues }); + } + return respondWithValidationErrors(res, parsed.error.issues); + } + + const payload = parsed.data as { preview?: string | null }; + if (payload.preview === undefined) { + return res.status(400).json({ error: "Validation error", message: "Missing preview field" }); + } + + const updated = await prisma.drawing.updateMany({ + where: access.role === "owner" ? { id, userId: req.user.id } : { id }, + data: { preview: payload.preview }, + }); + + if (updated.count === 0) { + return res.status(404).json({ error: "Drawing not found" }); + } + + invalidateDrawingsCache(); + return res.json({ success: true }); + })); + + app.delete("/drawings/:id", requireAuth, asyncHandler(async (req, res) => { + if (!req.user) return res.status(401).json({ error: "Unauthorized" }); + const id = getRouteIdParam(req.params.id); + if (!id) return res.status(400).json({ error: "Validation error", message: "Invalid id parameter" }); + + const drawing = await prisma.drawing.findFirst({ where: { id, userId: req.user.id } }); + if (!drawing) return res.status(404).json({ error: "Drawing not found" }); + + const deleteResult = await prisma.drawing.deleteMany({ + where: { id, userId: req.user.id }, + }); + if (deleteResult.count === 0) { + return res.status(404).json({ error: "Drawing not found" }); + } + invalidateDrawingsCache(); + + if (config.enableAuditLogging) { + await logAuditEvent({ + userId: req.user.id, + action: "drawing_deleted", + resource: `drawing:${id}`, + ipAddress: req.ip || req.connection.remoteAddress || undefined, + userAgent: req.headers["user-agent"] || undefined, + details: { drawingId: id, drawingName: drawing.name }, + }); + } + + return res.json({ success: true }); + })); + + app.post("/drawings/:id/duplicate", requireAuth, asyncHandler(async (req, res) => { + if (!req.user) return res.status(401).json({ error: "Unauthorized" }); + + const id = getRouteIdParam(req.params.id); + if (!id) return res.status(400).json({ error: "Validation error", message: "Invalid id parameter" }); + + const access = await resolveDrawingAccess({ + prisma, + drawingId: id, + userId: req.user.id, + }); + if (!access) return res.status(404).json({ error: "Original drawing not found" }); + + let duplicatedCollectionId = access.role === "owner" ? access.drawing.collectionId : null; + if (access.role === "owner" && isTrashCollectionId(access.drawing.collectionId, req.user.id)) { + await ensureTrashCollection(prisma, req.user.id); + duplicatedCollectionId = getUserTrashCollectionId(req.user.id); + } + + const newDrawing = await prisma.drawing.create({ + data: { + name: `${access.drawing.name} (Copy)`, + elements: access.drawing.elements, + appState: access.drawing.appState, + files: access.drawing.files, + userId: req.user.id, + collectionId: duplicatedCollectionId, + version: 1, + }, + }); + invalidateDrawingsCache(); + + return res.json({ + ...newDrawing, + accessRole: "owner", + collectionId: toPublicTrashCollectionId(newDrawing.collectionId, req.user.id), + elements: parseJsonField(newDrawing.elements, []), + appState: parseJsonField(newDrawing.appState, {}), + files: parseJsonField(newDrawing.files, {}), + }); + })); +}; diff --git a/backend/src/routes/dashboard/index.ts b/backend/src/routes/dashboard/index.ts new file mode 100644 index 00000000..82888cfa --- /dev/null +++ b/backend/src/routes/dashboard/index.ts @@ -0,0 +1,16 @@ +import express from "express"; +import { registerCollectionRoutes } from "./collections"; +import { registerDrawingRoutes } from "./drawings"; +import { registerLibraryRoutes } from "./library"; +import { DashboardRouteDeps } from "./types"; + +export const registerDashboardRoutes = ( + app: express.Express, + deps: DashboardRouteDeps +) => { + registerDrawingRoutes(app, deps); + registerCollectionRoutes(app, deps); + registerLibraryRoutes(app, deps); +}; + +export type { DashboardRouteDeps } from "./types"; diff --git a/backend/src/routes/dashboard/library.ts b/backend/src/routes/dashboard/library.ts new file mode 100644 index 00000000..d159c55f --- /dev/null +++ b/backend/src/routes/dashboard/library.ts @@ -0,0 +1,37 @@ +import express from "express"; +import { DashboardRouteDeps } from "./types"; + +export const registerLibraryRoutes = ( + app: express.Express, + deps: DashboardRouteDeps +) => { + const { prisma, requireAuth, asyncHandler, parseJsonField } = deps; + + app.get("/library", requireAuth, asyncHandler(async (req, res) => { + if (!req.user) return res.status(401).json({ error: "Unauthorized" }); + + const libraryId = `user_${req.user.id}`; + const library = await prisma.library.findUnique({ where: { id: libraryId } }); + if (!library) return res.json({ items: [] }); + + return res.json({ items: parseJsonField(library.items, []) }); + })); + + app.put("/library", requireAuth, asyncHandler(async (req, res) => { + if (!req.user) return res.status(401).json({ error: "Unauthorized" }); + + const { items } = req.body; + if (!Array.isArray(items)) { + return res.status(400).json({ error: "Items must be an array" }); + } + + const libraryId = `user_${req.user.id}`; + const library = await prisma.library.upsert({ + where: { id: libraryId }, + update: { items: JSON.stringify(items) }, + create: { id: libraryId, items: JSON.stringify(items) }, + }); + + return res.json({ items: parseJsonField(library.items, []) }); + })); +}; diff --git a/backend/src/routes/dashboard/trash.ts b/backend/src/routes/dashboard/trash.ts new file mode 100644 index 00000000..1a008142 --- /dev/null +++ b/backend/src/routes/dashboard/trash.ts @@ -0,0 +1,20 @@ +export const getUserTrashCollectionId = (userId: string): string => `trash:${userId}`; + +export const isTrashCollectionId = ( + collectionId: string | null | undefined, + userId: string +): boolean => + Boolean(collectionId) && + (collectionId === "trash" || collectionId === getUserTrashCollectionId(userId)); + +export const toInternalTrashCollectionId = ( + collectionId: string | null | undefined, + userId: string +): string | null | undefined => + collectionId === "trash" ? getUserTrashCollectionId(userId) : collectionId; + +export const toPublicTrashCollectionId = ( + collectionId: string | null | undefined, + userId: string +): string | null | undefined => + isTrashCollectionId(collectionId, userId) ? "trash" : collectionId; diff --git a/backend/src/routes/dashboard/types.ts b/backend/src/routes/dashboard/types.ts new file mode 100644 index 00000000..1ccac0af --- /dev/null +++ b/backend/src/routes/dashboard/types.ts @@ -0,0 +1,59 @@ +import express from "express"; +import { z } from "zod"; +import { Prisma, PrismaClient } from "../../generated/client"; +import { AuthModeService } from "../../auth/authMode"; +import { CollabSessionManager } from "../../server/collabSession"; + +export type SortField = "name" | "createdAt" | "updatedAt"; +export type SortDirection = "asc" | "desc"; + +type BuildDrawingsCacheKey = (keyParts: { + userId: string; + searchTerm: string; + collectionFilter: string; + includeData: boolean; + sortField: SortField; + sortDirection: SortDirection; +}) => string; + +type EnsureTrashCollection = ( + db: Prisma.TransactionClient | PrismaClient, + userId: string +) => Promise; + +type LogAuditEvent = (params: { + userId: string; + action: string; + resource?: string; + ipAddress?: string; + userAgent?: string; + details?: Record; +}) => Promise; + +export type DashboardRouteDeps = { + prisma: PrismaClient; + authModeService: AuthModeService; + requireAuth: express.RequestHandler; + asyncHandler: ( + fn: (req: express.Request, res: express.Response, next: express.NextFunction) => Promise + ) => express.RequestHandler; + parseJsonField: (rawValue: string | null | undefined, fallback: T) => T; + sanitizeText: (input: unknown, maxLength?: number) => string; + validateImportedDrawing: (data: unknown) => boolean; + drawingCreateSchema: z.ZodTypeAny; + drawingUpdateSchema: z.ZodTypeAny; + respondWithValidationErrors: (res: express.Response, issues: z.ZodIssue[]) => void; + collectionNameSchema: z.ZodTypeAny; + ensureTrashCollection: EnsureTrashCollection; + invalidateDrawingsCache: () => void; + buildDrawingsCacheKey: BuildDrawingsCacheKey; + getCachedDrawingsBody: (key: string) => Buffer | null; + cacheDrawingsResponse: (key: string, payload: unknown) => Buffer; + collabSessionManager: CollabSessionManager; + MAX_PAGE_SIZE: number; + config: { + nodeEnv: string; + enableAuditLogging: boolean; + }; + logAuditEvent: LogAuditEvent; +}; diff --git a/backend/src/routes/importExport.ts b/backend/src/routes/importExport.ts new file mode 100644 index 00000000..6831f8f5 --- /dev/null +++ b/backend/src/routes/importExport.ts @@ -0,0 +1,2 @@ +export { registerImportExportRoutes } from "./importExport/index"; +export type { RegisterImportExportDeps } from "./importExport/index"; diff --git a/backend/src/routes/importExport/excalidashImportRoutes.ts b/backend/src/routes/importExport/excalidashImportRoutes.ts new file mode 100644 index 00000000..780cc496 --- /dev/null +++ b/backend/src/routes/importExport/excalidashImportRoutes.ts @@ -0,0 +1,432 @@ +import { promises as fsPromises } from "fs"; +import JSZip from "jszip"; +import { v4 as uuidv4 } from "uuid"; +import { + RegisterImportExportDeps, + ImportValidationError, + assertSafeZipArchive, + excalidashManifestSchemaV1, + findFirstDuplicate, + getSafeZipEntry, + getUserTrashCollectionId, + resolveSafeUploadedFilePath, + sanitizeDrawingData, +} from "./shared"; + +export const registerExcalidashImportRoutes = (deps: RegisterImportExportDeps) => { + const { + app, + prisma, + requireAuth, + asyncHandler, + upload, + uploadDir, + sanitizeText, + validateImportedDrawing, + ensureTrashCollection, + invalidateDrawingsCache, + removeFileIfExists, + MAX_IMPORT_ARCHIVE_ENTRIES, + MAX_IMPORT_COLLECTIONS, + MAX_IMPORT_DRAWINGS, + MAX_IMPORT_MANIFEST_BYTES, + MAX_IMPORT_DRAWING_BYTES, + MAX_IMPORT_TOTAL_EXTRACTED_BYTES, + } = deps; + + app.post("/import/excalidash/verify", requireAuth, upload.single("archive"), asyncHandler(async (req, res) => { + if (!req.user) return res.status(401).json({ error: "Unauthorized" }); + if (!req.file) return res.status(400).json({ error: "No file uploaded" }); + + let stagedPath: string; + try { + stagedPath = await resolveSafeUploadedFilePath( + { filename: req.file.filename }, + uploadDir + ); + } catch (error) { + if (error instanceof ImportValidationError) { + return res.status(error.status).json({ error: "Invalid upload", message: error.message }); + } + throw error; + } + try { + const buffer = await fsPromises.readFile(stagedPath); + const zip = await JSZip.loadAsync(buffer); + try { + assertSafeZipArchive(zip, MAX_IMPORT_ARCHIVE_ENTRIES); + } catch (error) { + if (error instanceof ImportValidationError) { + return res.status(error.status).json({ error: "Invalid backup", message: error.message }); + } + throw error; + } + + const manifestFile = getSafeZipEntry(zip, "excalidash.manifest.json"); + if (!manifestFile) { + return res.status(400).json({ error: "Invalid backup", message: "Missing excalidash.manifest.json" }); + } + const rawManifest = await manifestFile.async("string"); + if (Buffer.byteLength(rawManifest, "utf8") > MAX_IMPORT_MANIFEST_BYTES) { + return res.status(400).json({ + error: "Invalid backup manifest", + message: "excalidash.manifest.json is too large", + }); + } + + let manifestJson: unknown; + try { + manifestJson = JSON.parse(rawManifest); + } catch { + return res.status(400).json({ + error: "Invalid backup manifest", + message: "excalidash.manifest.json is not valid JSON", + }); + } + const parsed = excalidashManifestSchemaV1.safeParse(manifestJson); + if (!parsed.success) { + return res.status(400).json({ + error: "Invalid backup manifest", + message: "Malformed excalidash.manifest.json", + }); + } + const manifest = parsed.data; + if (manifest.collections.length > MAX_IMPORT_COLLECTIONS) { + return res.status(400).json({ + error: "Invalid backup manifest", + message: `Too many collections (max ${MAX_IMPORT_COLLECTIONS})`, + }); + } + if (manifest.drawings.length > MAX_IMPORT_DRAWINGS) { + return res.status(400).json({ + error: "Invalid backup manifest", + message: `Too many drawings (max ${MAX_IMPORT_DRAWINGS})`, + }); + } + + const duplicateCollectionId = findFirstDuplicate(manifest.collections.map((c) => c.id)); + if (duplicateCollectionId) { + return res.status(400).json({ + error: "Invalid backup manifest", + message: `Duplicate collection id in manifest: ${duplicateCollectionId}`, + }); + } + const duplicateDrawingId = findFirstDuplicate(manifest.drawings.map((d) => d.id)); + if (duplicateDrawingId) { + return res.status(400).json({ + error: "Invalid backup manifest", + message: `Duplicate drawing id in manifest: ${duplicateDrawingId}`, + }); + } + const duplicateDrawingPath = findFirstDuplicate(manifest.drawings.map((d) => d.filePath)); + if (duplicateDrawingPath) { + return res.status(400).json({ + error: "Invalid backup manifest", + message: `Duplicate drawing file path in manifest: ${duplicateDrawingPath}`, + }); + } + for (const drawing of manifest.drawings) { + if (!getSafeZipEntry(zip, drawing.filePath)) { + return res.status(400).json({ + error: "Invalid backup", + message: `Missing drawing file: ${drawing.filePath}`, + }); + } + } + + return res.json({ + valid: true, + formatVersion: manifest.formatVersion, + exportedAt: manifest.exportedAt, + excalidashBackendVersion: manifest.excalidashBackendVersion || null, + collections: manifest.collections.length, + drawings: manifest.drawings.length, + }); + } finally { + await removeFileIfExists(stagedPath); + } + })); + + app.post("/import/excalidash", requireAuth, upload.single("archive"), asyncHandler(async (req, res) => { + if (!req.user) return res.status(401).json({ error: "Unauthorized" }); + if (!req.file) return res.status(400).json({ error: "No file uploaded" }); + + let stagedPath: string; + try { + stagedPath = await resolveSafeUploadedFilePath( + { filename: req.file.filename }, + uploadDir + ); + } catch (error) { + if (error instanceof ImportValidationError) { + return res.status(error.status).json({ error: "Invalid upload", message: error.message }); + } + throw error; + } + try { + const buffer = await fsPromises.readFile(stagedPath); + const zip = await JSZip.loadAsync(buffer); + try { + assertSafeZipArchive(zip, MAX_IMPORT_ARCHIVE_ENTRIES); + } catch (error) { + if (error instanceof ImportValidationError) { + return res.status(error.status).json({ error: "Invalid backup", message: error.message }); + } + throw error; + } + + const manifestFile = getSafeZipEntry(zip, "excalidash.manifest.json"); + if (!manifestFile) { + return res.status(400).json({ error: "Invalid backup", message: "Missing excalidash.manifest.json" }); + } + const rawManifest = await manifestFile.async("string"); + if (Buffer.byteLength(rawManifest, "utf8") > MAX_IMPORT_MANIFEST_BYTES) { + return res.status(400).json({ + error: "Invalid backup manifest", + message: "excalidash.manifest.json is too large", + }); + } + + let manifestJson: unknown; + try { + manifestJson = JSON.parse(rawManifest); + } catch { + return res.status(400).json({ + error: "Invalid backup manifest", + message: "excalidash.manifest.json is not valid JSON", + }); + } + const parsed = excalidashManifestSchemaV1.safeParse(manifestJson); + if (!parsed.success) { + return res.status(400).json({ + error: "Invalid backup manifest", + message: "Malformed excalidash.manifest.json", + }); + } + const manifest = parsed.data; + + if (manifest.collections.length > MAX_IMPORT_COLLECTIONS) { + return res.status(400).json({ + error: "Invalid backup manifest", + message: `Too many collections (max ${MAX_IMPORT_COLLECTIONS})`, + }); + } + if (manifest.drawings.length > MAX_IMPORT_DRAWINGS) { + return res.status(400).json({ + error: "Invalid backup manifest", + message: `Too many drawings (max ${MAX_IMPORT_DRAWINGS})`, + }); + } + + const duplicateCollectionId = findFirstDuplicate(manifest.collections.map((c) => c.id)); + if (duplicateCollectionId) { + return res.status(400).json({ + error: "Invalid backup manifest", + message: `Duplicate collection id in manifest: ${duplicateCollectionId}`, + }); + } + const duplicateDrawingId = findFirstDuplicate(manifest.drawings.map((d) => d.id)); + if (duplicateDrawingId) { + return res.status(400).json({ + error: "Invalid backup manifest", + message: `Duplicate drawing id in manifest: ${duplicateDrawingId}`, + }); + } + const duplicateDrawingPath = findFirstDuplicate(manifest.drawings.map((d) => d.filePath)); + if (duplicateDrawingPath) { + return res.status(400).json({ + error: "Invalid backup manifest", + message: `Duplicate drawing file path in manifest: ${duplicateDrawingPath}`, + }); + } + + type PreparedImportDrawing = { + id: string; + name: string; + version: number | undefined; + collectionId: string | null; + sanitized: ReturnType; + }; + const preparedDrawings: PreparedImportDrawing[] = []; + let extractedBytes = Buffer.byteLength(rawManifest, "utf8"); + try { + for (const d of manifest.drawings) { + const entry = getSafeZipEntry(zip, d.filePath); + if (!entry) throw new ImportValidationError(`Missing drawing file: ${d.filePath}`); + + const raw = await entry.async("string"); + const rawSize = Buffer.byteLength(raw, "utf8"); + if (rawSize > MAX_IMPORT_DRAWING_BYTES) { + throw new ImportValidationError(`Drawing is too large: ${d.filePath}`); + } + extractedBytes += rawSize; + if (extractedBytes > MAX_IMPORT_TOTAL_EXTRACTED_BYTES) { + throw new ImportValidationError("Backup contents exceed maximum import size"); + } + + let parsedJson: any; + try { + parsedJson = JSON.parse(raw) as any; + } catch { + throw new ImportValidationError(`Drawing JSON is invalid: ${d.filePath}`); + } + + const imported = { + name: d.name, + elements: Array.isArray(parsedJson?.elements) ? parsedJson.elements : [], + appState: + typeof parsedJson?.appState === "object" && parsedJson.appState !== null + ? parsedJson.appState + : {}, + files: + typeof parsedJson?.files === "object" && parsedJson.files !== null + ? parsedJson.files + : {}, + preview: null as string | null, + collectionId: d.collectionId, + }; + + if (!validateImportedDrawing(imported)) { + throw new ImportValidationError(`Drawing failed validation: ${d.filePath}`); + } + + preparedDrawings.push({ + id: d.id, + name: sanitizeText(imported.name, 255) || "Untitled Drawing", + version: typeof d.version === "number" ? d.version : undefined, + collectionId: d.collectionId, + sanitized: sanitizeDrawingData(imported), + }); + } + } catch (error) { + if (error instanceof ImportValidationError) { + return res.status(error.status).json({ error: "Invalid backup", message: error.message }); + } + throw error; + } + + const result = await prisma.$transaction(async (tx) => { + const trashCollectionId = getUserTrashCollectionId(req.user!.id); + const collectionIdMap = new Map(); + let collectionsCreated = 0; + let collectionsUpdated = 0; + let collectionIdConflicts = 0; + let drawingsCreated = 0; + let drawingsUpdated = 0; + let drawingIdConflicts = 0; + + const needsTrash = + manifest.collections.some((c) => c.id === "trash") || + preparedDrawings.some((d) => d.collectionId === "trash"); + if (needsTrash) await ensureTrashCollection(tx, req.user!.id); + + for (const c of manifest.collections) { + if (c.id === "trash") { + collectionIdMap.set("trash", trashCollectionId); + continue; + } + + const existing = await tx.collection.findUnique({ where: { id: c.id } }); + if (!existing) { + await tx.collection.create({ + data: { id: c.id, name: sanitizeText(c.name, 100) || "Collection", userId: req.user!.id }, + }); + collectionIdMap.set(c.id, c.id); + collectionsCreated += 1; + continue; + } + + if (existing.userId === req.user!.id) { + await tx.collection.update({ + where: { id: c.id }, + data: { name: sanitizeText(c.name, 100) || "Collection" }, + }); + collectionIdMap.set(c.id, c.id); + collectionsUpdated += 1; + continue; + } + + const newId = uuidv4(); + await tx.collection.create({ + data: { id: newId, name: sanitizeText(c.name, 100) || "Collection", userId: req.user!.id }, + }); + collectionIdMap.set(c.id, newId); + collectionsCreated += 1; + collectionIdConflicts += 1; + } + + const resolveCollectionId = (collectionId: string | null): string | null => { + if (!collectionId) return null; + if (collectionId === "trash") return trashCollectionId; + return collectionIdMap.get(collectionId) || null; + }; + + for (const prepared of preparedDrawings) { + const targetCollectionId = resolveCollectionId(prepared.collectionId); + const existing = await tx.drawing.findUnique({ where: { id: prepared.id } }); + if (!existing) { + await tx.drawing.create({ + data: { + id: prepared.id, + name: prepared.name, + elements: JSON.stringify(prepared.sanitized.elements), + appState: JSON.stringify(prepared.sanitized.appState), + files: JSON.stringify(prepared.sanitized.files || {}), + preview: prepared.sanitized.preview ?? null, + version: prepared.version ?? 1, + userId: req.user!.id, + collectionId: targetCollectionId, + }, + }); + drawingsCreated += 1; + continue; + } + + if (existing.userId === req.user!.id) { + await tx.drawing.update({ + where: { id: prepared.id }, + data: { + name: prepared.name, + elements: JSON.stringify(prepared.sanitized.elements), + appState: JSON.stringify(prepared.sanitized.appState), + files: JSON.stringify(prepared.sanitized.files || {}), + preview: prepared.sanitized.preview ?? null, + version: prepared.version ?? existing.version, + collectionId: targetCollectionId, + }, + }); + drawingsUpdated += 1; + continue; + } + + const newId = uuidv4(); + await tx.drawing.create({ + data: { + id: newId, + name: prepared.name, + elements: JSON.stringify(prepared.sanitized.elements), + appState: JSON.stringify(prepared.sanitized.appState), + files: JSON.stringify(prepared.sanitized.files || {}), + preview: prepared.sanitized.preview ?? null, + version: prepared.version ?? 1, + userId: req.user!.id, + collectionId: targetCollectionId, + }, + }); + drawingsCreated += 1; + drawingIdConflicts += 1; + } + + return { + collections: { created: collectionsCreated, updated: collectionsUpdated, idConflicts: collectionIdConflicts }, + drawings: { created: drawingsCreated, updated: drawingsUpdated, idConflicts: drawingIdConflicts }, + }; + }); + + invalidateDrawingsCache(); + return res.json({ success: true, message: "Backup imported successfully", ...result }); + } finally { + await removeFileIfExists(stagedPath); + } + })); +}; diff --git a/backend/src/routes/importExport/exportRoutes.ts b/backend/src/routes/importExport/exportRoutes.ts new file mode 100644 index 00000000..fabf2e37 --- /dev/null +++ b/backend/src/routes/importExport/exportRoutes.ts @@ -0,0 +1,163 @@ +import archiver from "archiver"; +import { Prisma } from "../../generated/client"; +import { + RegisterImportExportDeps, + assertSafeArchivePath, + getUserTrashCollectionId, + isTrashCollectionId, + makeUniqueName, + sanitizePathSegment, + toPublicTrashCollectionId, +} from "./shared"; + +export const registerExcalidashExportRoute = (deps: RegisterImportExportDeps) => { + const { + app, + prisma, + requireAuth, + asyncHandler, + getBackendVersion, + parseJsonField, + } = deps; + + app.get("/export/excalidash", requireAuth, asyncHandler(async (req, res) => { + if (!req.user) return res.status(401).json({ error: "Unauthorized" }); + const trashCollectionId = getUserTrashCollectionId(req.user.id); + + const extParam = typeof req.query.ext === "string" ? req.query.ext.toLowerCase() : ""; + const zipSuffix = extParam === "zip"; + const date = new Date().toISOString().split("T")[0]; + const filename = zipSuffix + ? `excalidash-backup-${date}.excalidash.zip` + : `excalidash-backup-${date}.excalidash`; + + const exportedAt = new Date().toISOString(); + const drawings = await prisma.drawing.findMany({ + where: { userId: req.user.id }, + include: { collection: true }, + }); + const userCollections = await prisma.collection.findMany({ + where: { userId: req.user.id }, + }); + + const hasInternalTrashCollection = userCollections.some((collection) => collection.id === trashCollectionId); + const normalizedUserCollections = userCollections.filter( + (collection) => !(hasInternalTrashCollection && collection.id === "trash") + ); + const hasTrashDrawings = drawings.some((drawing) => + isTrashCollectionId(drawing.collectionId, req.user!.id) + ); + const collectionsToExport = [...normalizedUserCollections]; + if ( + hasTrashDrawings && + !collectionsToExport.some((collection) => + isTrashCollectionId(collection.id, req.user!.id) + ) + ) { + const trash = await prisma.collection.findFirst({ + where: { userId: req.user.id, id: { in: [trashCollectionId, "trash"] } }, + }); + if (trash) collectionsToExport.push(trash); + } + + const exportSource = `${req.protocol}://${req.get("host")}`; + const usedFolderNames = new Set(); + const unorganizedFolder = makeUniqueName("Unorganized", usedFolderNames); + const folderByCollectionId = new Map(); + for (const collection of collectionsToExport) { + const base = sanitizePathSegment(collection.name, "Collection"); + const folder = makeUniqueName(base, usedFolderNames); + folderByCollectionId.set(collection.id, folder); + } + + type DrawingWithCollection = Prisma.DrawingGetPayload<{ include: { collection: true } }>; + const drawingsManifest = drawings.map((drawing: DrawingWithCollection) => { + const folder = drawing.collectionId + ? folderByCollectionId.get(drawing.collectionId) || unorganizedFolder + : unorganizedFolder; + const fileNameBase = sanitizePathSegment(drawing.name, "Untitled"); + const fileName = `${fileNameBase}__${drawing.id.slice(0, 8)}.excalidraw`; + return { + id: drawing.id, + name: drawing.name, + filePath: `${folder}/${fileName}`, + collectionId: toPublicTrashCollectionId(drawing.collectionId, req.user!.id), + version: drawing.version, + createdAt: drawing.createdAt.toISOString(), + updatedAt: drawing.updatedAt.toISOString(), + }; + }); + + const manifestCollections = collectionsToExport + .map((collection) => ({ + id: toPublicTrashCollectionId(collection.id, req.user!.id) || collection.id, + name: isTrashCollectionId(collection.id, req.user!.id) ? "Trash" : collection.name, + folder: folderByCollectionId.get(collection.id) || sanitizePathSegment(collection.name, "Collection"), + createdAt: collection.createdAt.toISOString(), + updatedAt: collection.updatedAt.toISOString(), + })) + .filter((collection, index, all) => all.findIndex((c) => c.id === collection.id) === index); + + const manifest = { + format: "excalidash" as const, + formatVersion: 1 as const, + exportedAt, + excalidashBackendVersion: getBackendVersion(), + userId: req.user.id, + unorganizedFolder, + collections: manifestCollections, + drawings: drawingsManifest, + }; + + res.setHeader("Content-Type", "application/zip"); + res.setHeader("Content-Disposition", `attachment; filename="${filename}"`); + + const archive = archiver("zip", { zlib: { level: 9 } }); + archive.on("error", (err) => { + console.error("Archive error:", err); + res.status(500).json({ error: "Failed to create archive" }); + }); + archive.pipe(res); + + archive.append(JSON.stringify(manifest, null, 2), { name: "excalidash.manifest.json" }); + + const drawingsManifestById = new Map(drawingsManifest.map((d) => [d.id, d])); + for (const drawing of drawings) { + const meta = drawingsManifestById.get(drawing.id); + if (!meta) continue; + const drawingData = { + type: "excalidraw" as const, + version: 2 as const, + source: exportSource, + elements: parseJsonField(drawing.elements, [] as unknown[]), + appState: parseJsonField(drawing.appState, {} as Record), + files: parseJsonField(drawing.files, {} as Record), + excalidash: { + drawingId: drawing.id, + collectionId: drawing.collectionId ?? null, + exportedAt, + }, + }; + assertSafeArchivePath(meta.filePath); + archive.append(JSON.stringify(drawingData, null, 2), { name: meta.filePath }); + } + + const readme = `ExcaliDash Backup (.excalidash) + +This file is a zip archive containing a versioned ExcaliDash manifest and your drawings, +organized into folders by collection. + +Files: +- excalidash.manifest.json (required) +- /*.excalidraw + +ExportedAt: ${exportedAt} +FormatVersion: 1 +BackendVersion: ${getBackendVersion()} +Collections: ${collectionsToExport.length} +Drawings: ${drawings.length} +`; + archive.append(readme, { name: "README.txt" }); + await archive.finalize(); + })); +}; diff --git a/backend/src/routes/importExport/index.ts b/backend/src/routes/importExport/index.ts new file mode 100644 index 00000000..4265f9f4 --- /dev/null +++ b/backend/src/routes/importExport/index.ts @@ -0,0 +1,12 @@ +import { registerExcalidashImportRoutes } from "./excalidashImportRoutes"; +import { registerExcalidashExportRoute } from "./exportRoutes"; +import { registerLegacySqliteImportRoutes } from "./legacySqliteImportRoutes"; +import { RegisterImportExportDeps } from "./shared"; + +export const registerImportExportRoutes = (deps: RegisterImportExportDeps) => { + registerExcalidashExportRoute(deps); + registerExcalidashImportRoutes(deps); + registerLegacySqliteImportRoutes(deps); +}; + +export type { RegisterImportExportDeps } from "./shared"; diff --git a/backend/src/routes/importExport/legacySqliteImportRoutes.ts b/backend/src/routes/importExport/legacySqliteImportRoutes.ts new file mode 100644 index 00000000..ab509ba2 --- /dev/null +++ b/backend/src/routes/importExport/legacySqliteImportRoutes.ts @@ -0,0 +1,414 @@ +import { v4 as uuidv4 } from "uuid"; +import { + RegisterImportExportDeps, + ImportValidationError, + findFirstDuplicate, + findSqliteTable, + getCurrentLatestPrismaMigrationName, + getUserTrashCollectionId, + normalizeNonEmptyId, + openReadonlySqliteDb, + parseOptionalJson, + resolveSafeUploadedFilePath, + sanitizeDrawingData, +} from "./shared"; + +export const registerLegacySqliteImportRoutes = (deps: RegisterImportExportDeps) => { + const { + app, + prisma, + requireAuth, + asyncHandler, + upload, + uploadDir, + backendRoot, + sanitizeText, + validateImportedDrawing, + ensureTrashCollection, + invalidateDrawingsCache, + removeFileIfExists, + verifyDatabaseIntegrityAsync, + MAX_IMPORT_COLLECTIONS, + MAX_IMPORT_DRAWINGS, + } = deps; + + app.post("/import/sqlite/legacy/verify", requireAuth, upload.single("db"), asyncHandler(async (req, res) => { + if (!req.user) return res.status(401).json({ error: "Unauthorized" }); + if (!req.file) return res.status(400).json({ error: "No file uploaded" }); + + let stagedPath: string; + try { + stagedPath = await resolveSafeUploadedFilePath( + { filename: req.file.filename }, + uploadDir + ); + } catch (error) { + if (error instanceof ImportValidationError) { + return res.status(error.status).json({ error: "Invalid upload", message: error.message }); + } + throw error; + } + try { + const isValid = await verifyDatabaseIntegrityAsync(stagedPath); + if (!isValid) return res.status(400).json({ error: "Invalid database format" }); + + let db: any | null = null; + try { + db = openReadonlySqliteDb(stagedPath); + const tables: string[] = db + .prepare("SELECT name FROM sqlite_master WHERE type='table'") + .all() + .map((row: any) => String(row.name)); + + const drawingTable = findSqliteTable(tables, ["Drawing", "drawings"]); + const collectionTable = findSqliteTable(tables, ["Collection", "collections"]); + if (!drawingTable) { + return res.status(400).json({ error: "Invalid legacy DB", message: "Missing Drawing table" }); + } + + const drawingsCount = Number(db.prepare(`SELECT COUNT(1) as c FROM "${drawingTable}"`).get()?.c ?? 0); + const collectionsCount = collectionTable + ? Number(db.prepare(`SELECT COUNT(1) as c FROM "${collectionTable}"`).get()?.c ?? 0) + : 0; + if (drawingsCount > MAX_IMPORT_DRAWINGS) { + return res.status(400).json({ + error: "Invalid legacy DB", + message: `Too many drawings (max ${MAX_IMPORT_DRAWINGS})`, + }); + } + if (collectionsCount > MAX_IMPORT_COLLECTIONS) { + return res.status(400).json({ + error: "Invalid legacy DB", + message: `Too many collections (max ${MAX_IMPORT_COLLECTIONS})`, + }); + } + + const duplicateDrawingIdRow = db + .prepare( + `SELECT id FROM "${drawingTable}" WHERE id IS NOT NULL GROUP BY id HAVING COUNT(1) > 1 LIMIT 1` + ) + .get(); + if (duplicateDrawingIdRow?.id) { + return res.status(400).json({ + error: "Invalid legacy DB", + message: `Duplicate drawing id in legacy DB: ${String(duplicateDrawingIdRow.id)}`, + }); + } + if (collectionTable) { + const duplicateCollectionIdRow = db + .prepare( + `SELECT id FROM "${collectionTable}" WHERE id IS NOT NULL GROUP BY id HAVING COUNT(1) > 1 LIMIT 1` + ) + .get(); + if (duplicateCollectionIdRow?.id) { + return res.status(400).json({ + error: "Invalid legacy DB", + message: `Duplicate collection id in legacy DB: ${String(duplicateCollectionIdRow.id)}`, + }); + } + } + + let latestMigration: string | null = null; + const migrationsTable = findSqliteTable(tables, ["_prisma_migrations"]); + if (migrationsTable) { + try { + const row = db + .prepare( + `SELECT migration_name as name, finished_at as finishedAt FROM "${migrationsTable}" ORDER BY finished_at DESC LIMIT 1` + ) + .get(); + if (row?.name) latestMigration = String(row.name); + } catch { + latestMigration = null; + } + } + + return res.json({ + valid: true, + drawings: drawingsCount, + collections: collectionsCount, + latestMigration, + currentLatestMigration: await getCurrentLatestPrismaMigrationName(backendRoot), + }); + } catch { + return res.status(500).json({ + error: "Legacy DB support unavailable", + message: + "Failed to open the SQLite database for inspection. If you're on Node < 22, you may need to rebuild native dependencies (e.g. `cd backend && npm rebuild better-sqlite3`).", + }); + } finally { + try { + db?.close?.(); + } catch {} + } + } finally { + await removeFileIfExists(stagedPath); + } + })); + + app.post("/import/sqlite/legacy", requireAuth, upload.single("db"), asyncHandler(async (req, res) => { + if (!req.user) return res.status(401).json({ error: "Unauthorized" }); + if (!req.file) return res.status(400).json({ error: "No file uploaded" }); + + let stagedPath: string; + try { + stagedPath = await resolveSafeUploadedFilePath( + { filename: req.file.filename }, + uploadDir + ); + } catch (error) { + if (error instanceof ImportValidationError) { + return res.status(error.status).json({ error: "Invalid upload", message: error.message }); + } + throw error; + } + try { + const isValid = await verifyDatabaseIntegrityAsync(stagedPath); + if (!isValid) return res.status(400).json({ error: "Invalid database format" }); + + let legacyDb: any | null = null; + try { + legacyDb = openReadonlySqliteDb(stagedPath); + const tables: string[] = legacyDb + .prepare("SELECT name FROM sqlite_master WHERE type='table'") + .all() + .map((row: any) => String(row.name)); + + const drawingTable = findSqliteTable(tables, ["Drawing", "drawings"]); + const collectionTable = findSqliteTable(tables, ["Collection", "collections"]); + if (!drawingTable) { + return res.status(400).json({ error: "Invalid legacy DB", message: "Missing Drawing table" }); + } + + const importedCollections: any[] = collectionTable + ? legacyDb.prepare(`SELECT * FROM "${collectionTable}"`).all() + : []; + const importedDrawings: any[] = legacyDb.prepare(`SELECT * FROM "${drawingTable}"`).all(); + + if (importedCollections.length > MAX_IMPORT_COLLECTIONS) { + return res.status(400).json({ + error: "Invalid legacy DB", + message: `Too many collections (max ${MAX_IMPORT_COLLECTIONS})`, + }); + } + if (importedDrawings.length > MAX_IMPORT_DRAWINGS) { + return res.status(400).json({ + error: "Invalid legacy DB", + message: `Too many drawings (max ${MAX_IMPORT_DRAWINGS})`, + }); + } + + const importedCollectionIds = importedCollections + .map((c) => normalizeNonEmptyId(c?.id)) + .filter((id): id is string => id !== null); + const duplicateCollectionId = findFirstDuplicate(importedCollectionIds); + if (duplicateCollectionId) { + return res.status(400).json({ + error: "Invalid legacy DB", + message: `Duplicate collection id in legacy DB: ${duplicateCollectionId}`, + }); + } + + const importedDrawingIds = importedDrawings + .map((d) => normalizeNonEmptyId(d?.id)) + .filter((id): id is string => id !== null); + const duplicateDrawingId = findFirstDuplicate(importedDrawingIds); + if (duplicateDrawingId) { + return res.status(400).json({ + error: "Invalid legacy DB", + message: `Duplicate drawing id in legacy DB: ${duplicateDrawingId}`, + }); + } + + type PreparedLegacyDrawing = { + importedId: string | null; + name: string; + sanitized: ReturnType; + collectionIdRaw: unknown; + collectionNameRaw: unknown; + versionRaw: unknown; + }; + + const preparedDrawings: PreparedLegacyDrawing[] = []; + for (const d of importedDrawings) { + const importPayload = { + name: typeof d.name === "string" ? d.name : "Untitled Drawing", + elements: parseOptionalJson(d.elements, []), + appState: parseOptionalJson>(d.appState, {}), + files: parseOptionalJson>(d.files, {}), + preview: typeof d.preview === "string" ? d.preview : null, + collectionId: null as string | null, + }; + + if (!validateImportedDrawing(importPayload)) { + return res.status(400).json({ + error: "Invalid imported drawing", + message: "Legacy database contains invalid drawing data", + }); + } + + preparedDrawings.push({ + importedId: typeof d.id === "string" ? d.id : null, + name: sanitizeText(importPayload.name, 255) || "Untitled Drawing", + sanitized: sanitizeDrawingData(importPayload), + collectionIdRaw: d.collectionId, + collectionNameRaw: d.collectionName, + versionRaw: d.version, + }); + } + + const result = await prisma.$transaction(async (tx) => { + const trashCollectionId = getUserTrashCollectionId(req.user!.id); + const hasTrash = importedDrawings.some((d) => String(d.collectionId || "") === "trash"); + if (hasTrash) await ensureTrashCollection(tx, req.user!.id); + + const collectionIdMap = new Map(); + let collectionsCreated = 0; + let collectionsUpdated = 0; + let collectionIdConflicts = 0; + let drawingsCreated = 0; + let drawingsUpdated = 0; + let drawingIdConflicts = 0; + + for (const c of importedCollections) { + const importedId = typeof c.id === "string" ? c.id : null; + const name = typeof c.name === "string" ? c.name : "Collection"; + + if (importedId === "trash" || name === "Trash") { + collectionIdMap.set(importedId || "trash", trashCollectionId); + continue; + } + + if (!importedId) { + const newId = uuidv4(); + await tx.collection.create({ + data: { id: newId, name: sanitizeText(name, 100) || "Collection", userId: req.user!.id }, + }); + collectionIdMap.set(`__name:${name}`, newId); + collectionsCreated += 1; + continue; + } + + const existing = await tx.collection.findUnique({ where: { id: importedId } }); + if (!existing) { + await tx.collection.create({ + data: { id: importedId, name: sanitizeText(name, 100) || "Collection", userId: req.user!.id }, + }); + collectionIdMap.set(importedId, importedId); + collectionsCreated += 1; + continue; + } + if (existing.userId === req.user!.id) { + await tx.collection.update({ + where: { id: importedId }, + data: { name: sanitizeText(name, 100) || "Collection" }, + }); + collectionIdMap.set(importedId, importedId); + collectionsUpdated += 1; + continue; + } + + const newId = uuidv4(); + await tx.collection.create({ + data: { id: newId, name: sanitizeText(name, 100) || "Collection", userId: req.user!.id }, + }); + collectionIdMap.set(importedId, newId); + collectionsCreated += 1; + collectionIdConflicts += 1; + } + + const resolveImportedCollectionId = ( + rawCollectionId: unknown, + rawCollectionName: unknown + ): string | null => { + const id = typeof rawCollectionId === "string" ? rawCollectionId : null; + const name = typeof rawCollectionName === "string" ? rawCollectionName : null; + + if (id === "trash" || name === "Trash") return trashCollectionId; + if (id && collectionIdMap.has(id)) return collectionIdMap.get(id)!; + if (name && collectionIdMap.has(`__name:${name}`)) return collectionIdMap.get(`__name:${name}`)!; + return null; + }; + + for (const d of preparedDrawings) { + const resolvedCollectionId = resolveImportedCollectionId(d.collectionIdRaw, d.collectionNameRaw); + const existing = d.importedId ? await tx.drawing.findUnique({ where: { id: d.importedId } }) : null; + + if (!existing) { + const idToUse = d.importedId || uuidv4(); + await tx.drawing.create({ + data: { + id: idToUse, + name: d.name, + elements: JSON.stringify(d.sanitized.elements), + appState: JSON.stringify(d.sanitized.appState), + files: JSON.stringify(d.sanitized.files || {}), + preview: d.sanitized.preview ?? null, + version: Number.isFinite(Number(d.versionRaw)) ? Number(d.versionRaw) : 1, + userId: req.user!.id, + collectionId: resolvedCollectionId ?? null, + }, + }); + drawingsCreated += 1; + continue; + } + + if (existing.userId === req.user!.id) { + await tx.drawing.update({ + where: { id: existing.id }, + data: { + name: d.name, + elements: JSON.stringify(d.sanitized.elements), + appState: JSON.stringify(d.sanitized.appState), + files: JSON.stringify(d.sanitized.files || {}), + preview: d.sanitized.preview ?? null, + version: Number.isFinite(Number(d.versionRaw)) ? Number(d.versionRaw) : existing.version, + collectionId: resolvedCollectionId ?? null, + }, + }); + drawingsUpdated += 1; + continue; + } + + const newId = uuidv4(); + await tx.drawing.create({ + data: { + id: newId, + name: d.name, + elements: JSON.stringify(d.sanitized.elements), + appState: JSON.stringify(d.sanitized.appState), + files: JSON.stringify(d.sanitized.files || {}), + preview: d.sanitized.preview ?? null, + version: Number.isFinite(Number(d.versionRaw)) ? Number(d.versionRaw) : 1, + userId: req.user!.id, + collectionId: resolvedCollectionId ?? null, + }, + }); + drawingsCreated += 1; + drawingIdConflicts += 1; + } + + return { + collections: { created: collectionsCreated, updated: collectionsUpdated, idConflicts: collectionIdConflicts }, + drawings: { created: drawingsCreated, updated: drawingsUpdated, idConflicts: drawingIdConflicts }, + }; + }); + + invalidateDrawingsCache(); + return res.json({ success: true, ...result }); + } catch { + return res.status(500).json({ + error: "Legacy DB support unavailable", + message: + "Failed to open the SQLite database for import. If you're on Node < 22, you may need to rebuild native dependencies (e.g. `cd backend && npm rebuild better-sqlite3`).", + }); + } finally { + try { + legacyDb?.close?.(); + } catch {} + } + } finally { + await removeFileIfExists(stagedPath); + } + })); +}; diff --git a/backend/src/routes/importExport/shared.ts b/backend/src/routes/importExport/shared.ts new file mode 100644 index 00000000..26e7cf1b --- /dev/null +++ b/backend/src/routes/importExport/shared.ts @@ -0,0 +1,255 @@ +import express from "express"; +import path from "path"; +import { promises as fsPromises } from "fs"; +import JSZip from "jszip"; +import { z } from "zod"; +import { Prisma, PrismaClient } from "../../generated/client"; +import { sanitizeDrawingData } from "../../security"; + +export class ImportValidationError extends Error { + status: number; + + constructor(message: string, status = 400) { + super(message); + this.name = "ImportValidationError"; + this.status = status; + } +} + +export const excalidashManifestSchemaV1 = z.object({ + format: z.literal("excalidash"), + formatVersion: z.literal(1), + exportedAt: z.string().min(1), + excalidashBackendVersion: z.string().optional(), + userId: z.string().optional(), + unorganizedFolder: z.string().min(1), + collections: z.array( + z.object({ + id: z.string().min(1), + name: z.string(), + folder: z.string().min(1), + createdAt: z.string().optional(), + updatedAt: z.string().optional(), + }) + ), + drawings: z.array( + z.object({ + id: z.string().min(1), + name: z.string(), + filePath: z.string().min(1), + collectionId: z.string().nullable(), + version: z.number().int().optional(), + createdAt: z.string().optional(), + updatedAt: z.string().optional(), + }) + ), +}); + +export type RegisterImportExportDeps = { + app: express.Express; + prisma: PrismaClient; + requireAuth: express.RequestHandler; + asyncHandler: ( + fn: (req: express.Request, res: express.Response, next: express.NextFunction) => Promise + ) => express.RequestHandler; + upload: any; + uploadDir: string; + backendRoot: string; + getBackendVersion: () => string; + parseJsonField: (rawValue: string | null | undefined, fallback: T) => T; + sanitizeText: (input: unknown, maxLength?: number) => string; + validateImportedDrawing: (data: unknown) => boolean; + ensureTrashCollection: ( + db: Prisma.TransactionClient | PrismaClient, + userId: string + ) => Promise; + invalidateDrawingsCache: () => void; + removeFileIfExists: (filePath?: string) => Promise; + verifyDatabaseIntegrityAsync: (filePath: string) => Promise; + MAX_IMPORT_ARCHIVE_ENTRIES: number; + MAX_IMPORT_COLLECTIONS: number; + MAX_IMPORT_DRAWINGS: number; + MAX_IMPORT_MANIFEST_BYTES: number; + MAX_IMPORT_DRAWING_BYTES: number; + MAX_IMPORT_TOTAL_EXTRACTED_BYTES: number; +}; + +const getZipEntries = (zip: JSZip) => Object.values(zip.files).filter((entry) => !entry.dir); + +export const normalizeArchivePath = (filePath: string): string => + path.posix.normalize(filePath.replace(/\\/g, "/")); + +export const assertSafeArchivePath = (filePath: string) => { + const normalized = normalizeArchivePath(filePath); + if ( + normalized.length === 0 || + path.posix.isAbsolute(normalized) || + normalized === ".." || + normalized.startsWith("../") || + normalized.includes("\0") + ) { + throw new ImportValidationError(`Unsafe archive path: ${filePath}`); + } +}; + +export const assertSafeZipArchive = (zip: JSZip, maxEntries: number) => { + const entries = getZipEntries(zip); + if (entries.length > maxEntries) { + throw new ImportValidationError("Archive contains too many files"); + } + for (const entry of entries) { + assertSafeArchivePath(entry.name); + } +}; + +export const getSafeZipEntry = (zip: JSZip, filePath: string) => { + const normalizedPath = normalizeArchivePath(filePath); + assertSafeArchivePath(normalizedPath); + return zip.file(normalizedPath); +}; + +export const sanitizePathSegment = (input: string, fallback: string): string => { + const value = typeof input === "string" ? input.trim() : ""; + const cleaned = value + .replace(/[<>:"/\\|?*\x00-\x1F]/g, "_") + .replace(/\s+/g, " ") + .slice(0, 120) + .trim(); + return cleaned.length > 0 ? cleaned : fallback; +}; + +export const makeUniqueName = (base: string, used: Set): string => { + let candidate = base; + let n = 2; + while (used.has(candidate)) { + candidate = `${base}__${n}`; + n += 1; + } + used.add(candidate); + return candidate; +}; + +export const findFirstDuplicate = (values: string[]): string | null => { + const seen = new Set(); + for (const value of values) { + if (seen.has(value)) return value; + seen.add(value); + } + return null; +}; + +export const normalizeNonEmptyId = (value: unknown): string | null => { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +}; + +export const getUserTrashCollectionId = (userId: string): string => `trash:${userId}`; + +export const isTrashCollectionId = ( + collectionId: string | null | undefined, + userId: string +): boolean => + Boolean(collectionId) && + (collectionId === "trash" || collectionId === getUserTrashCollectionId(userId)); + +export const toPublicTrashCollectionId = ( + collectionId: string | null | undefined, + userId: string +): string | null => + isTrashCollectionId(collectionId, userId) ? "trash" : collectionId ?? null; + +export const findSqliteTable = (tables: string[], candidates: string[]): string | null => { + const byLower = new Map(tables.map((t) => [t.toLowerCase(), t])); + for (const candidate of candidates) { + const found = byLower.get(candidate.toLowerCase()); + if (found) return found; + } + return null; +}; + +export const parseOptionalJson = (raw: unknown, fallback: T): T => { + if (typeof raw === "string") { + try { + return JSON.parse(raw) as T; + } catch { + return fallback; + } + } + if (typeof raw === "object" && raw !== null) { + return raw as T; + } + return fallback; +}; + +const isPathInsideDirectory = (candidatePath: string, rootDir: string): boolean => { + const relativePath = path.relative(rootDir, candidatePath); + return ( + relativePath === "" || + (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)) + ); +}; + +const isSafeMulterTempFilename = (value: string): boolean => + /^[a-f0-9]{32}$/.test(value); + +export const resolveSafeUploadedFilePath = async ( + fileMeta: { filename?: unknown }, + uploadRoot: string +): Promise => { + const absoluteUploadRoot = path.resolve(uploadRoot); + let canonicalUploadRoot = absoluteUploadRoot; + + try { + canonicalUploadRoot = await fsPromises.realpath(absoluteUploadRoot); + } catch { + throw new ImportValidationError("Invalid upload path"); + } + + const filename = typeof fileMeta.filename === "string" ? fileMeta.filename : ""; + if (!isSafeMulterTempFilename(filename)) { + throw new ImportValidationError("Invalid upload path"); + } + + const joinedPath = path.resolve(canonicalUploadRoot, filename); + if (!isPathInsideDirectory(joinedPath, canonicalUploadRoot)) { + throw new ImportValidationError("Invalid upload path"); + } + + return joinedPath; +}; + +export const openReadonlySqliteDb = (filePath: string): any => { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { DatabaseSync } = require("node:sqlite") as any; + return new DatabaseSync(filePath, { + readOnly: true, + enableForeignKeyConstraints: false, + }); + } catch { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const Database = require("better-sqlite3") as any; + return new Database(filePath, { readonly: true, fileMustExist: true }); + } +}; + +export const getCurrentLatestPrismaMigrationName = async ( + backendRoot: string +): Promise => { + try { + const migrationsDir = path.resolve(backendRoot, "prisma/migrations"); + const entries = await fsPromises.readdir(migrationsDir, { withFileTypes: true }); + const dirs = entries + .filter((e) => e.isDirectory()) + .map((e) => e.name) + .filter((name) => !name.startsWith(".")); + if (dirs.length === 0) return null; + dirs.sort(); + return dirs[dirs.length - 1] || null; + } catch { + return null; + } +}; + +export { sanitizeDrawingData }; diff --git a/backend/src/security.ts b/backend/src/security.ts index bd90c339..fb3bffb5 100644 --- a/backend/src/security.ts +++ b/backend/src/security.ts @@ -30,7 +30,9 @@ let activeConfig: SecurityConfig = { ...defaultConfig }; * Configure security settings * @param config Partial configuration to merge with defaults */ -export const configureSecuritySettings = (config: Partial): void => { +export const configureSecuritySettings = ( + config: Partial +): void => { activeConfig = { ...activeConfig, ...config }; }; @@ -99,11 +101,45 @@ export const sanitizeHtml = (input: string): string => { export const sanitizeSvg = (svgContent: string): string => { if (typeof svgContent !== "string") return ""; - return purify + const safeImageDataUrlPattern = + /^data:image\/(?:png|jpe?g|gif|webp|avif|bmp);base64,[a-z0-9+/=\s]+$/i; + + const sanitizeSvgImageTags = (content: string): string => + content.replace(/]*>/gi, (imageTag) => { + const hrefMatch = + imageTag.match(/\shref\s*=\s*"([^"]*)"/i) ?? + imageTag.match(/\shref\s*=\s*'([^']*)'/i) ?? + imageTag.match(/\sxlink:href\s*=\s*"([^"]*)"/i) ?? + imageTag.match(/\sxlink:href\s*=\s*'([^']*)'/i); + + const hrefValue = hrefMatch?.[1]?.trim(); + if (!hrefValue || !safeImageDataUrlPattern.test(hrefValue)) { + return ""; + } + + const withoutXlinkHref = imageTag.replace( + /\sxlink:href\s*=\s*(?:"[^"]*"|'[^']*')/gi, + "" + ); + + if (/\shref\s*=/i.test(withoutXlinkHref)) { + return withoutXlinkHref.replace( + /\shref\s*=\s*(?:"[^"]*"|'[^']*')/i, + ` href="${hrefValue}"` + ); + } + + return withoutXlinkHref.replace(/ { "tspan", ], ALLOWED_ATTR: [ + "xmlns", + "xmlns:xlink", + "version", + "id", + "viewBox", + "preserveAspectRatio", "x", "y", "width", @@ -131,14 +173,29 @@ export const sanitizeSvg = (svgContent: string): string => { "points", "d", "fill", + "fill-opacity", + "fill-rule", "stroke", "stroke-width", + "stroke-opacity", + "stroke-linecap", + "stroke-linejoin", + "stroke-miterlimit", + "stroke-dasharray", + "stroke-dashoffset", "opacity", "transform", + "vector-effect", + "patternUnits", + "patternContentUnits", "font-size", "font-family", + "font-weight", + "letter-spacing", "text-anchor", "dominant-baseline", + "href", + "xlink:href", ], FORBID_TAGS: [ "script", @@ -147,10 +204,8 @@ export const sanitizeSvg = (svgContent: string): string => { "object", "embed", "use", - "image", "style", "link", - "defs", "symbol", "marker", "clipPath", @@ -164,17 +219,41 @@ export const sanitizeSvg = (svgContent: string): string => { "onmouseover", "onfocus", "onblur", - "href", - "xlink:href", "src", "action", "style", "class", - "id", ], KEEP_CONTENT: true, }) .trim(); + + return sanitizeSvgImageTags(sanitized).trim(); +}; + +const SAFE_PREVIEW_DATA_URL_PATTERN = + /^data:image\/(?:webp|png|jpe?g);base64,[a-z0-9+/=\s]+$/i; +const MAX_PREVIEW_SIZE = 300_000; + +export const sanitizePreview = ( + previewContent: string | null | undefined +): string | null => { + if (previewContent === null || previewContent === undefined) return null; + if (typeof previewContent !== "string") return null; + const trimmed = previewContent.trim(); + if (trimmed.length === 0) return null; + if (trimmed.length > MAX_PREVIEW_SIZE) return null; + + if (SAFE_PREVIEW_DATA_URL_PATTERN.test(trimmed)) { + return trimmed; + } + + if (/^]/i.test(trimmed)) { + const sanitized = sanitizeSvg(trimmed); + return sanitized.length > 0 && sanitized.length <= MAX_PREVIEW_SIZE ? sanitized : null; + } + + return null; }; export const sanitizeText = ( @@ -318,10 +397,13 @@ export const appStateSchema = z .optional() .nullable(), currentItemRoundness: z - .object({ - type: z.enum(["round", "sharp"]), - value: z.number().finite().min(0).max(1), - }) + .union([ + z.enum(["sharp", "round"]), + z.object({ + type: z.enum(["round", "sharp"]), + value: z.number().finite().min(0).max(1), + }), + ]) .optional() .nullable(), currentItemFontSize: z @@ -406,10 +488,7 @@ export const sanitizeDrawingData = (data: { const sanitizedElements = elementSchema.array().parse(data.elements); const sanitizedAppState = appStateSchema.parse(data.appState); - let sanitizedPreview = data.preview; - if (typeof sanitizedPreview === "string") { - sanitizedPreview = sanitizeSvg(sanitizedPreview); - } + const sanitizedPreview = sanitizePreview(data.preview); // Sanitize files object with special handling for dataURL let sanitizedFiles = data.files; @@ -427,10 +506,19 @@ export const sanitizeDrawingData = (data: { ]; // Dangerous URL protocols to block entirely - const dangerousProtocols = [/^javascript:/i, /^vbscript:/i, /^data:text\/html/i]; + const dangerousProtocols = [ + /^javascript:/i, + /^vbscript:/i, + /^data:text\/html/i, + ]; // Suspicious patterns for security validation within data URLs - const suspiciousPatterns = [/