diff --git a/.gitea/workflows/docker-build.yml b/.gitea/workflows/docker-build.yml index 799bcdd..4c9dc83 100644 --- a/.gitea/workflows/docker-build.yml +++ b/.gitea/workflows/docker-build.yml @@ -21,14 +21,24 @@ jobs: - name: Log in to the Container registry run: echo "${{ secrets.DOCKER_TOKEN }}" | docker login ${{ env.REGISTRY }} -u "${{ gitea.actor }}" --password-stdin - # Build standard using docker CLI - - name: Build Docker image + # Frontend image (nginx + SPA) + - name: Build frontend Docker image run: | LOWERCASE_IMAGE=$(echo "${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]') docker build -t ${{ env.REGISTRY }}/$LOWERCASE_IMAGE:latest . - # Push directly using docker push - - name: Push Docker image + - name: Push frontend Docker image run: | LOWERCASE_IMAGE=$(echo "${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]') docker push ${{ env.REGISTRY }}/$LOWERCASE_IMAGE:latest + + # Backend image (Express API) + - name: Build backend Docker image + run: | + LOWERCASE_IMAGE=$(echo "${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]') + docker build -t ${{ env.REGISTRY }}/$LOWERCASE_IMAGE-backend:latest ./server + + - name: Push backend Docker image + run: | + LOWERCASE_IMAGE=$(echo "${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]') + docker push ${{ env.REGISTRY }}/$LOWERCASE_IMAGE-backend:latest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..83069a0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# Dependencies +node_modules/ + +# Build output +dist/ +dist-ssr/ +*.local + +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Editor / OS +.vscode/* +!.vscode/extensions.json +.idea/ +.DS_Store +Thumbs.db + +# Vite / PWA generated +dev-dist/ + +# Backend embedded PGlite data +.pgdata/ +server/.pgdata/ + +# Env +.env +.env.* +!.env.example diff --git a/docker-compose.yml b/docker-compose.yml index f841d42..46739f0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,39 @@ services: restart: unless-stopped ports: - "8080:80" - # Optionnel: si vous avez des logos dans un dossier externe sur votre machine hôte - # volumes: - # - ./games-assets:/usr/share/nginx/html/games-assets + depends_on: + - backend + + backend: + build: + context: ./server + dockerfile: Dockerfile + container_name: skori-backend + restart: unless-stopped + environment: + NODE_ENV: production + PORT: 3001 + DATABASE_URL: postgres://skori:${POSTGRES_PASSWORD:-skori}@postgres:5432/skori + JWT_SECRET: ${JWT_SECRET:-change-me-in-production} + depends_on: + postgres: + condition: service_healthy + + postgres: + image: postgres:16-alpine + container_name: skori-postgres + restart: unless-stopped + environment: + POSTGRES_USER: skori + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-skori} + POSTGRES_DB: skori + volumes: + - skori-pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U skori -d skori"] + interval: 5s + timeout: 5s + retries: 10 + +volumes: + skori-pgdata: diff --git a/nginx.conf b/nginx.conf index 716d53e..9b2de44 100644 --- a/nginx.conf +++ b/nginx.conf @@ -4,6 +4,16 @@ server { root /usr/share/nginx/html; index index.html; + # Proxy des appels API vers le backend (même origine => cookie httpOnly OK) + location /api/ { + proxy_pass http://backend:3001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + # Important pour les Single Page Applications (React Router) location / { try_files $uri $uri/ /index.html; diff --git a/package-lock.json b/package-lock.json index 1df49d3..6e23369 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "board-score", + "name": "skori", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "board-score", + "name": "skori", "version": "1.0.0", "dependencies": { "@hookform/resolvers": "^3.3.2", diff --git a/server/.dockerignore b/server/.dockerignore new file mode 100644 index 0000000..932f75b --- /dev/null +++ b/server/.dockerignore @@ -0,0 +1,5 @@ +node_modules +dist +.pgdata +.env +npm-debug.log diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..1b0a0c1 --- /dev/null +++ b/server/.env.example @@ -0,0 +1,15 @@ +# Port the API listens on +PORT=3001 + +# Postgres connection string. If omitted, the server falls back to an +# embedded PGlite database (stored in ./.pgdata) — handy for local dev. +# DATABASE_URL=postgres://skori:skori@postgres:5432/skori + +# Secret used to sign JWT access tokens (CHANGE THIS in production) +JWT_SECRET=dev-secret-change-me + +# Comma-separated list of allowed CORS origins (only needed for cross-origin dev) +# CORS_ORIGINS=http://localhost:5173 + +# Set to "production" to enable Secure cookies (requires HTTPS) +NODE_ENV=development diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..2271031 --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,25 @@ +# ÉTAPE 1 : Build du backend TypeScript +FROM node:20-alpine AS builder + +WORKDIR /app + +COPY package*.json ./ +RUN npm install + +COPY . . +RUN npm run build + +# ÉTAPE 2 : Image de production (deps de prod uniquement) +FROM node:20-alpine + +WORKDIR /app +ENV NODE_ENV=production + +COPY package*.json ./ +RUN npm install --omit=dev + +COPY --from=builder /app/dist ./dist + +EXPOSE 3001 + +CMD ["node", "dist/index.js"] diff --git a/server/package-lock.json b/server/package-lock.json new file mode 100644 index 0000000..a44f73d --- /dev/null +++ b/server/package-lock.json @@ -0,0 +1,2006 @@ +{ + "name": "skori-server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "skori-server", + "version": "1.0.0", + "dependencies": { + "@electric-sql/pglite": "^0.2.12", + "bcryptjs": "^2.4.3", + "cookie-parser": "^1.4.6", + "cors": "^2.8.5", + "drizzle-orm": "^0.33.0", + "express": "^4.19.2", + "jsonwebtoken": "^9.0.2", + "pg": "^8.12.0", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/cookie-parser": "^1.4.7", + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/jsonwebtoken": "^9.0.6", + "@types/node": "^20.14.0", + "@types/pg": "^8.11.6", + "tsx": "^4.16.0", + "typescript": "^5.5.0" + } + }, + "node_modules/@electric-sql/pglite": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.2.17.tgz", + "integrity": "sha512-qEpKRT2oUaWDH6tjRxLHjdzMqRUGYDnGZlKrnL4dJ77JVMcP2Hpo3NYnOSPKdZdeec57B6QPprCUFg0picx5Pw==", + "license": "Apache-2.0" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cookie-parser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz", + "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.9", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.9.tgz", + "integrity": "sha512-QP2ESEe/ImWY0HDwNAnK9PvEffUyhLTnWkk7KXzHfyeWAnlrDe1fN77bXl6ia8KT3wPlmA7t9/VPRpnf4Ex9sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/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/mime": { + "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/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "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": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "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", + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/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/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/drizzle-orm": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.33.0.tgz", + "integrity": "sha512-SHy72R2Rdkz0LEq0PSG/IdvnT3nGiWuRk+2tXZQ90GVq/XQhpCzu/EFT3V2rox+w8MlkBQxifF8pCStNYnERfA==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/client-rds-data": ">=3", + "@cloudflare/workers-types": ">=3", + "@electric-sql/pglite": ">=0.1.1", + "@libsql/client": "*", + "@neondatabase/serverless": ">=0.1", + "@op-engineering/op-sqlite": ">=2", + "@opentelemetry/api": "^1.4.1", + "@planetscale/database": ">=1", + "@prisma/client": "*", + "@tidbcloud/serverless": "*", + "@types/better-sqlite3": "*", + "@types/pg": "*", + "@types/react": ">=18", + "@types/sql.js": "*", + "@vercel/postgres": ">=0.8.0", + "@xata.io/client": "*", + "better-sqlite3": ">=7", + "bun-types": "*", + "expo-sqlite": ">=13.2.0", + "knex": "*", + "kysely": "*", + "mysql2": ">=2", + "pg": ">=8", + "postgres": ">=3", + "react": ">=18", + "sql.js": ">=1", + "sqlite3": ">=5" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-rds-data": { + "optional": true + }, + "@cloudflare/workers-types": { + "optional": true + }, + "@electric-sql/pglite": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@op-engineering/op-sqlite": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@tidbcloud/serverless": { + "optional": true + }, + "@types/better-sqlite3": { + "optional": true + }, + "@types/pg": { + "optional": true + }, + "@types/react": { + "optional": true + }, + "@types/sql.js": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "expo-sqlite": { + "optional": true + }, + "knex": { + "optional": true + }, + "kysely": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "postgres": { + "optional": true + }, + "prisma": { + "optional": true + }, + "react": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + } + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/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/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/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/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/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", + "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.15.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz", + "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsx": { + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.0.tgz", + "integrity": "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..1bc3a81 --- /dev/null +++ b/server/package.json @@ -0,0 +1,35 @@ +{ + "name": "skori-server", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "typecheck": "tsc --noEmit", + "smoke": "tsx src/smoke-test.ts" + }, + "dependencies": { + "@electric-sql/pglite": "^0.2.12", + "bcryptjs": "^2.4.3", + "cookie-parser": "^1.4.6", + "cors": "^2.8.5", + "drizzle-orm": "^0.33.0", + "express": "^4.19.2", + "jsonwebtoken": "^9.0.2", + "pg": "^8.12.0", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/cookie-parser": "^1.4.7", + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/jsonwebtoken": "^9.0.6", + "@types/node": "^20.14.0", + "@types/pg": "^8.11.6", + "tsx": "^4.16.0", + "typescript": "^5.5.0" + } +} diff --git a/server/src/app.ts b/server/src/app.ts new file mode 100644 index 0000000..6bc13d5 --- /dev/null +++ b/server/src/app.ts @@ -0,0 +1,31 @@ +import express from "express"; +import cors from "cors"; +import cookieParser from "cookie-parser"; +import { env } from "./config/env.js"; +import { errorHandler } from "./middleware/errorHandler.js"; +import { authRouter } from "./modules/auth/routes.js"; +import { usersRouter } from "./modules/users/routes.js"; +import { syncRouter } from "./modules/sync/routes.js"; + +export function createApp() { + const app = express(); + + app.use(express.json({ limit: "10mb" })); + app.use(cookieParser()); + + // Same-origin in production (served behind nginx). CORS only needed when the + // frontend dev server runs on a different origin than the API. + if (env.corsOrigins.length > 0) { + app.use(cors({ origin: env.corsOrigins, credentials: true })); + } + + app.get("/api/v1/health", (_req, res) => res.json({ ok: true })); + + app.use("/api/v1/auth", authRouter); + app.use("/api/v1", usersRouter); + app.use("/api/v1/sync", syncRouter); + + app.use(errorHandler); + + return app; +} diff --git a/server/src/config/env.ts b/server/src/config/env.ts new file mode 100644 index 0000000..cfddb19 --- /dev/null +++ b/server/src/config/env.ts @@ -0,0 +1,17 @@ +export const env = { + port: Number(process.env.PORT) || 3001, + databaseUrl: process.env.DATABASE_URL || "", + jwtSecret: process.env.JWT_SECRET || "dev-secret-change-me", + isProduction: process.env.NODE_ENV === "production", + corsOrigins: (process.env.CORS_ORIGINS || "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + // Embedded PGlite data directory (used only when DATABASE_URL is empty) + pgliteDir: process.env.PGLITE_DIR || "./.pgdata", +}; + +// Token lifetimes +export const ACCESS_TOKEN_TTL = "15m"; +export const REFRESH_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days +export const REFRESH_COOKIE_NAME = "skori_refresh"; diff --git a/server/src/db/client.ts b/server/src/db/client.ts new file mode 100644 index 0000000..fd0d7a4 --- /dev/null +++ b/server/src/db/client.ts @@ -0,0 +1,41 @@ +import { drizzle as drizzlePg } from "drizzle-orm/node-postgres"; +import { drizzle as drizzlePglite } from "drizzle-orm/pglite"; +import type { NodePgDatabase } from "drizzle-orm/node-postgres"; +import pg from "pg"; +import { PGlite } from "@electric-sql/pglite"; +import * as schema from "./schema.js"; +import { env } from "../config/env.js"; +import { ensureSchema } from "./init.js"; + +// Both drivers expose the same query API for our schema; we normalise the +// exported type to the node-postgres flavour to keep call sites simple. +export type AppDatabase = NodePgDatabase; + +let dbInstance: AppDatabase | null = null; + +export async function initDb(): Promise { + if (dbInstance) return dbInstance; + + if (env.databaseUrl) { + const pool = new pg.Pool({ connectionString: env.databaseUrl }); + dbInstance = drizzlePg(pool, { schema }); + console.log("[db] using PostgreSQL (node-postgres)"); + } else { + const client = new PGlite(env.pgliteDir); + await client.waitReady; + dbInstance = drizzlePglite(client, { + schema, + }) as unknown as AppDatabase; + console.log(`[db] using embedded PGlite (${env.pgliteDir})`); + } + + await ensureSchema(dbInstance); + return dbInstance; +} + +export function getDb(): AppDatabase { + if (!dbInstance) { + throw new Error("Database not initialised — call initDb() first"); + } + return dbInstance; +} diff --git a/server/src/db/init.ts b/server/src/db/init.ts new file mode 100644 index 0000000..134574a --- /dev/null +++ b/server/src/db/init.ts @@ -0,0 +1,70 @@ +import { sql } from "drizzle-orm"; +import type { AppDatabase } from "./client.js"; + +// Idempotent schema creation. Runs on every boot; safe to run repeatedly. +// Kept as plain SQL so it works identically on node-postgres and PGlite, +// without a separate migration toolchain for this single-instance app. +const STATEMENTS = [ + `CREATE TABLE IF NOT EXISTS users ( + id uuid PRIMARY KEY, + email text NOT NULL UNIQUE, + username text UNIQUE, + password_hash text NOT NULL, + display_name text NOT NULL, + avatar_url text, + created_at bigint NOT NULL, + updated_at bigint NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS refresh_tokens ( + id uuid PRIMARY KEY, + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash text NOT NULL, + expires_at bigint NOT NULL, + revoked_at bigint, + created_at bigint NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS refresh_tokens_hash_idx ON refresh_tokens (token_hash)`, + `CREATE TABLE IF NOT EXISTS locations ( + id uuid PRIMARY KEY, + owner_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name text NOT NULL, + address text, + created_at bigint NOT NULL, + updated_at bigint NOT NULL, + deleted_at bigint + )`, + `CREATE INDEX IF NOT EXISTS locations_owner_updated_idx ON locations (owner_id, updated_at)`, + `CREATE TABLE IF NOT EXISTS players ( + id uuid PRIMARY KEY, + owner_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name text NOT NULL, + avatar text, + created_at bigint NOT NULL, + updated_at bigint NOT NULL, + deleted_at bigint + )`, + `CREATE INDEX IF NOT EXISTS players_owner_updated_idx ON players (owner_id, updated_at)`, + `CREATE TABLE IF NOT EXISTS sessions ( + id uuid PRIMARY KEY, + owner_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + game_id text NOT NULL, + date_start bigint NOT NULL, + date_end bigint, + players jsonb NOT NULL, + rounds jsonb NOT NULL, + status text NOT NULL, + options jsonb NOT NULL, + winner_ids jsonb, + location_id uuid, + created_at bigint NOT NULL, + updated_at bigint NOT NULL, + deleted_at bigint + )`, + `CREATE INDEX IF NOT EXISTS sessions_owner_updated_idx ON sessions (owner_id, updated_at)`, +]; + +export async function ensureSchema(db: AppDatabase) { + for (const statement of STATEMENTS) { + await db.execute(sql.raw(statement)); + } +} diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts new file mode 100644 index 0000000..67a986f --- /dev/null +++ b/server/src/db/schema.ts @@ -0,0 +1,103 @@ +import { + pgTable, + uuid, + text, + bigint, + jsonb, + index, +} from "drizzle-orm/pg-core"; + +// Epoch-millis timestamps (matches the client's number-based model exactly). +const ms = (name: string) => bigint(name, { mode: "number" }); + +export const users = pgTable("users", { + id: uuid("id").primaryKey(), + email: text("email").notNull().unique(), + username: text("username").unique(), + passwordHash: text("password_hash").notNull(), + displayName: text("display_name").notNull(), + avatarUrl: text("avatar_url"), + createdAt: ms("created_at").notNull(), + updatedAt: ms("updated_at").notNull(), +}); + +export const refreshTokens = pgTable("refresh_tokens", { + id: uuid("id").primaryKey(), + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + tokenHash: text("token_hash").notNull(), + expiresAt: ms("expires_at").notNull(), + revokedAt: ms("revoked_at"), + createdAt: ms("created_at").notNull(), +}); + +export const locations = pgTable( + "locations", + { + id: uuid("id").primaryKey(), + ownerId: uuid("owner_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: text("name").notNull(), + address: text("address"), + createdAt: ms("created_at").notNull(), + updatedAt: ms("updated_at").notNull(), + deletedAt: ms("deleted_at"), + }, + (t) => ({ + ownerUpdatedIdx: index("locations_owner_updated_idx").on( + t.ownerId, + t.updatedAt, + ), + }), +); + +export const players = pgTable( + "players", + { + id: uuid("id").primaryKey(), + ownerId: uuid("owner_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: text("name").notNull(), + avatar: text("avatar"), + createdAt: ms("created_at").notNull(), + updatedAt: ms("updated_at").notNull(), + deletedAt: ms("deleted_at"), + }, + (t) => ({ + ownerUpdatedIdx: index("players_owner_updated_idx").on( + t.ownerId, + t.updatedAt, + ), + }), +); + +export const sessions = pgTable( + "sessions", + { + id: uuid("id").primaryKey(), + ownerId: uuid("owner_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + gameId: text("game_id").notNull(), + dateStart: ms("date_start").notNull(), + dateEnd: ms("date_end"), + players: jsonb("players").notNull(), + rounds: jsonb("rounds").notNull(), + status: text("status").notNull(), + options: jsonb("options").notNull(), + winnerIds: jsonb("winner_ids"), + locationId: uuid("location_id"), + createdAt: ms("created_at").notNull(), + updatedAt: ms("updated_at").notNull(), + deletedAt: ms("deleted_at"), + }, + (t) => ({ + ownerUpdatedIdx: index("sessions_owner_updated_idx").on( + t.ownerId, + t.updatedAt, + ), + }), +); diff --git a/server/src/index.ts b/server/src/index.ts new file mode 100644 index 0000000..994a0bc --- /dev/null +++ b/server/src/index.ts @@ -0,0 +1,16 @@ +import { createApp } from "./app.js"; +import { initDb } from "./db/client.js"; +import { env } from "./config/env.js"; + +async function main() { + await initDb(); + const app = createApp(); + app.listen(env.port, () => { + console.log(`[skori-server] listening on http://localhost:${env.port}`); + }); +} + +main().catch((err) => { + console.error("Fatal startup error:", err); + process.exit(1); +}); diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts new file mode 100644 index 0000000..5768bba --- /dev/null +++ b/server/src/middleware/auth.ts @@ -0,0 +1,29 @@ +import type { Request, Response, NextFunction } from "express"; +import { verifyAccessToken } from "../utils/jwt.js"; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Express { + interface Request { + userId?: string; + } + } +} + +export function requireAuth( + req: Request, + res: Response, + next: NextFunction, +): void { + const header = req.headers.authorization; + const token = header?.startsWith("Bearer ") ? header.slice(7) : null; + const userId = token ? verifyAccessToken(token) : null; + + if (!userId) { + res.status(401).json({ error: "unauthorized" }); + return; + } + + req.userId = userId; + next(); +} diff --git a/server/src/middleware/errorHandler.ts b/server/src/middleware/errorHandler.ts new file mode 100644 index 0000000..07b056e --- /dev/null +++ b/server/src/middleware/errorHandler.ts @@ -0,0 +1,39 @@ +import type { Request, Response, NextFunction } from "express"; +import { ZodError } from "zod"; + +export class HttpError extends Error { + constructor( + public status: number, + message: string, + ) { + super(message); + } +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function errorHandler( + err: unknown, + _req: Request, + res: Response, + _next: NextFunction, +): void { + if (err instanceof ZodError) { + res.status(400).json({ error: "validation_error", details: err.issues }); + return; + } + if (err instanceof HttpError) { + res.status(err.status).json({ error: err.message }); + return; + } + console.error("[error]", err); + res.status(500).json({ error: "internal_error" }); +} + +// Wraps async route handlers so thrown errors reach the error handler. +export function asyncHandler< + T extends (req: Request, res: Response, next: NextFunction) => Promise, +>(fn: T) { + return (req: Request, res: Response, next: NextFunction) => { + fn(req, res, next).catch(next); + }; +} diff --git a/server/src/modules/auth/controller.ts b/server/src/modules/auth/controller.ts new file mode 100644 index 0000000..4a2f96b --- /dev/null +++ b/server/src/modules/auth/controller.ts @@ -0,0 +1,70 @@ +import type { Request, Response } from "express"; +import { z } from "zod"; +import * as authService from "./service.js"; +import { HttpError } from "../../middleware/errorHandler.js"; +import { + env, + REFRESH_COOKIE_NAME, + REFRESH_TOKEN_TTL_MS, +} from "../../config/env.js"; + +const registerSchema = z.object({ + email: z.string().email(), + password: z.string().min(8).max(200), + displayName: z.string().min(1).max(80), + username: z + .string() + .min(3) + .max(30) + .regex(/^[a-zA-Z0-9_.-]+$/) + .optional(), + desiredId: z.string().uuid().optional(), +}); + +const loginSchema = z.object({ + email: z.string().email(), + password: z.string().min(1), +}); + +function setRefreshCookie(res: Response, token: string) { + res.cookie(REFRESH_COOKIE_NAME, token, { + httpOnly: true, + secure: env.isProduction, + sameSite: "strict", + maxAge: REFRESH_TOKEN_TTL_MS, + path: "/api/v1/auth", + }); +} + +function clearRefreshCookie(res: Response) { + res.clearCookie(REFRESH_COOKIE_NAME, { path: "/api/v1/auth" }); +} + +export async function register(req: Request, res: Response) { + const input = registerSchema.parse(req.body); + const result = await authService.register(input); + setRefreshCookie(res, result.refreshToken); + res.status(201).json({ user: result.user, accessToken: result.accessToken }); +} + +export async function login(req: Request, res: Response) { + const input = loginSchema.parse(req.body); + const result = await authService.login(input); + setRefreshCookie(res, result.refreshToken); + res.json({ user: result.user, accessToken: result.accessToken }); +} + +export async function refresh(req: Request, res: Response) { + const token = req.cookies?.[REFRESH_COOKIE_NAME]; + if (!token) throw new HttpError(401, "missing_refresh_token"); + const result = await authService.refresh(token); + setRefreshCookie(res, result.refreshToken); + res.json({ user: result.user, accessToken: result.accessToken }); +} + +export async function logout(req: Request, res: Response) { + const token = req.cookies?.[REFRESH_COOKIE_NAME]; + if (token) await authService.logout(token); + clearRefreshCookie(res); + res.json({ ok: true }); +} diff --git a/server/src/modules/auth/routes.ts b/server/src/modules/auth/routes.ts new file mode 100644 index 0000000..2d48273 --- /dev/null +++ b/server/src/modules/auth/routes.ts @@ -0,0 +1,10 @@ +import { Router } from "express"; +import { asyncHandler } from "../../middleware/errorHandler.js"; +import * as controller from "./controller.js"; + +export const authRouter = Router(); + +authRouter.post("/register", asyncHandler(controller.register)); +authRouter.post("/login", asyncHandler(controller.login)); +authRouter.post("/refresh", asyncHandler(controller.refresh)); +authRouter.post("/logout", asyncHandler(controller.logout)); diff --git a/server/src/modules/auth/service.ts b/server/src/modules/auth/service.ts new file mode 100644 index 0000000..e3a3288 --- /dev/null +++ b/server/src/modules/auth/service.ts @@ -0,0 +1,185 @@ +import { and, eq, gt, isNull } from "drizzle-orm"; +import { getDb } from "../../db/client.js"; +import { users, refreshTokens } from "../../db/schema.js"; +import { hashPassword, verifyPassword } from "../../utils/password.js"; +import { signAccessToken } from "../../utils/jwt.js"; +import { generateRefreshToken, hashToken, newId } from "../../utils/tokens.js"; +import { REFRESH_TOKEN_TTL_MS } from "../../config/env.js"; +import { HttpError } from "../../middleware/errorHandler.js"; + +export interface PublicUser { + id: string; + email: string; + username: string | null; + displayName: string; + avatarUrl: string | null; +} + +export interface AuthResult { + user: PublicUser; + accessToken: string; + refreshToken: string; +} + +function toPublicUser(u: typeof users.$inferSelect): PublicUser { + return { + id: u.id, + email: u.email, + username: u.username, + displayName: u.displayName, + avatarUrl: u.avatarUrl, + }; +} + +async function issueTokens( + userId: string, +): Promise<{ accessToken: string; refreshToken: string }> { + const db = getDb(); + const now = Date.now(); + const { token, hash } = generateRefreshToken(); + + await db.insert(refreshTokens).values({ + id: newId(), + userId, + tokenHash: hash, + expiresAt: now + REFRESH_TOKEN_TTL_MS, + revokedAt: null, + createdAt: now, + }); + + return { accessToken: signAccessToken(userId), refreshToken: token }; +} + +export async function register(input: { + email: string; + password: string; + displayName: string; + username?: string; + desiredId?: string; +}): Promise { + const db = getDb(); + const email = input.email.toLowerCase().trim(); + + const existing = await db + .select({ id: users.id }) + .from(users) + .where(eq(users.email, email)) + .limit(1); + if (existing.length > 0) { + throw new HttpError(409, "email_taken"); + } + + if (input.username) { + const takenUsername = await db + .select({ id: users.id }) + .from(users) + .where(eq(users.username, input.username)) + .limit(1); + if (takenUsername.length > 0) { + throw new HttpError(409, "username_taken"); + } + } + + // Reuse the client's local profile id as the account id (zero-mapping sync). + let userId = input.desiredId || newId(); + if (input.desiredId) { + const clash = await db + .select({ id: users.id }) + .from(users) + .where(eq(users.id, input.desiredId)) + .limit(1); + if (clash.length > 0) userId = newId(); + } + + const now = Date.now(); + const passwordHash = await hashPassword(input.password); + + const [user] = await db + .insert(users) + .values({ + id: userId, + email, + username: input.username || null, + passwordHash, + displayName: input.displayName.trim() || email, + avatarUrl: null, + createdAt: now, + updatedAt: now, + }) + .returning(); + + const tokens = await issueTokens(user.id); + return { user: toPublicUser(user), ...tokens }; +} + +export async function login(input: { + email: string; + password: string; +}): Promise { + const db = getDb(); + const email = input.email.toLowerCase().trim(); + + const [user] = await db + .select() + .from(users) + .where(eq(users.email, email)) + .limit(1); + + if (!user || !(await verifyPassword(input.password, user.passwordHash))) { + throw new HttpError(401, "invalid_credentials"); + } + + const tokens = await issueTokens(user.id); + return { user: toPublicUser(user), ...tokens }; +} + +// Validates a refresh token and rotates it (single-use). Returns a fresh +// access token, a new refresh token, and the owning user. +export async function refresh(rawToken: string): Promise { + const db = getDb(); + const hash = hashToken(rawToken); + const now = Date.now(); + + const [row] = await db + .select() + .from(refreshTokens) + .where( + and( + eq(refreshTokens.tokenHash, hash), + isNull(refreshTokens.revokedAt), + gt(refreshTokens.expiresAt, now), + ), + ) + .limit(1); + + if (!row) { + throw new HttpError(401, "invalid_refresh_token"); + } + + // Rotate: revoke the used token before issuing a new one. + await db + .update(refreshTokens) + .set({ revokedAt: now }) + .where(eq(refreshTokens.id, row.id)); + + const [user] = await db + .select() + .from(users) + .where(eq(users.id, row.userId)) + .limit(1); + if (!user) { + throw new HttpError(401, "invalid_refresh_token"); + } + + const tokens = await issueTokens(user.id); + return { user: toPublicUser(user), ...tokens }; +} + +export async function logout(rawToken: string): Promise { + const db = getDb(); + const hash = hashToken(rawToken); + await db + .update(refreshTokens) + .set({ revokedAt: Date.now() }) + .where(eq(refreshTokens.tokenHash, hash)); +} diff --git a/server/src/modules/sync/controller.ts b/server/src/modules/sync/controller.ts new file mode 100644 index 0000000..f81c153 --- /dev/null +++ b/server/src/modules/sync/controller.ts @@ -0,0 +1,21 @@ +import type { Request, Response } from "express"; +import { z } from "zod"; +import * as syncService from "./service.js"; + +const pushSchema = z.object({ + locations: z.array(z.any()).optional(), + players: z.array(z.any()).optional(), + sessions: z.array(z.any()).optional(), +}); + +export async function pull(req: Request, res: Response) { + const since = Number(req.query.since) || 0; + const result = await syncService.pull(req.userId!, since); + res.json(result); +} + +export async function push(req: Request, res: Response) { + const payload = pushSchema.parse(req.body); + const result = await syncService.push(req.userId!, payload); + res.json(result); +} diff --git a/server/src/modules/sync/routes.ts b/server/src/modules/sync/routes.ts new file mode 100644 index 0000000..4a04758 --- /dev/null +++ b/server/src/modules/sync/routes.ts @@ -0,0 +1,9 @@ +import { Router } from "express"; +import { requireAuth } from "../../middleware/auth.js"; +import { asyncHandler } from "../../middleware/errorHandler.js"; +import * as controller from "./controller.js"; + +export const syncRouter = Router(); + +syncRouter.get("/", requireAuth, asyncHandler(controller.pull)); +syncRouter.post("/", requireAuth, asyncHandler(controller.push)); diff --git a/server/src/modules/sync/service.ts b/server/src/modules/sync/service.ts new file mode 100644 index 0000000..d5c4a9a --- /dev/null +++ b/server/src/modules/sync/service.ts @@ -0,0 +1,143 @@ +import { and, eq, gt } from "drizzle-orm"; +import { getDb } from "../../db/client.js"; +import { locations, players, sessions } from "../../db/schema.js"; + +// ---- Pull: everything changed since a cursor (tombstones included) ---- + +export async function pull(ownerId: string, since: number) { + const db = getDb(); + const serverTime = Date.now(); + + const [loc, pl, se] = await Promise.all([ + db + .select() + .from(locations) + .where(and(eq(locations.ownerId, ownerId), gt(locations.updatedAt, since))), + db + .select() + .from(players) + .where(and(eq(players.ownerId, ownerId), gt(players.updatedAt, since))), + db + .select() + .from(sessions) + .where(and(eq(sessions.ownerId, ownerId), gt(sessions.updatedAt, since))), + ]); + + return { serverTime, locations: loc, players: pl, sessions: se }; +} + +// ---- Push: upsert client records; server clock is authoritative ---- + +export interface PushPayload { + locations?: any[]; + players?: any[]; + sessions?: any[]; +} + +interface Applied { + id: string; + updatedAt: number; +} + +export async function push(ownerId: string, payload: PushPayload) { + const db = getDb(); + const now = Date.now(); + const applied = { + locations: [] as Applied[], + players: [] as Applied[], + sessions: [] as Applied[], + }; + + await db.transaction(async (tx) => { + for (const r of payload.locations ?? []) { + if (!r?.id) continue; + await tx + .insert(locations) + .values({ + id: r.id, + ownerId, + name: r.name ?? "", + address: r.address ?? null, + createdAt: r.createdAt ?? now, + updatedAt: now, + deletedAt: r.deletedAt ?? null, + }) + .onConflictDoUpdate({ + target: locations.id, + set: { + name: r.name ?? "", + address: r.address ?? null, + updatedAt: now, + deletedAt: r.deletedAt ?? null, + }, + }); + applied.locations.push({ id: r.id, updatedAt: now }); + } + + for (const r of payload.players ?? []) { + if (!r?.id) continue; + await tx + .insert(players) + .values({ + id: r.id, + ownerId, + name: r.name ?? "", + avatar: r.avatar ?? null, + createdAt: r.createdAt ?? now, + updatedAt: now, + deletedAt: r.deletedAt ?? null, + }) + .onConflictDoUpdate({ + target: players.id, + set: { + name: r.name ?? "", + avatar: r.avatar ?? null, + updatedAt: now, + deletedAt: r.deletedAt ?? null, + }, + }); + applied.players.push({ id: r.id, updatedAt: now }); + } + + for (const r of payload.sessions ?? []) { + if (!r?.id) continue; + await tx + .insert(sessions) + .values({ + id: r.id, + ownerId, + gameId: r.gameId ?? "", + dateStart: r.dateStart ?? now, + dateEnd: r.dateEnd ?? null, + players: r.players ?? [], + rounds: r.rounds ?? [], + status: r.status ?? "finished", + options: r.options ?? {}, + winnerIds: r.winnerIds ?? null, + locationId: r.locationId ?? null, + createdAt: r.dateStart ?? now, + updatedAt: now, + deletedAt: r.deletedAt ?? null, + }) + .onConflictDoUpdate({ + target: sessions.id, + set: { + gameId: r.gameId ?? "", + dateStart: r.dateStart ?? now, + dateEnd: r.dateEnd ?? null, + players: r.players ?? [], + rounds: r.rounds ?? [], + status: r.status ?? "finished", + options: r.options ?? {}, + winnerIds: r.winnerIds ?? null, + locationId: r.locationId ?? null, + updatedAt: now, + deletedAt: r.deletedAt ?? null, + }, + }); + applied.sessions.push({ id: r.id, updatedAt: now }); + } + }); + + return { serverTime: now, applied }; +} diff --git a/server/src/modules/users/controller.ts b/server/src/modules/users/controller.ts new file mode 100644 index 0000000..10ffe06 --- /dev/null +++ b/server/src/modules/users/controller.ts @@ -0,0 +1,63 @@ +import type { Request, Response } from "express"; +import { z } from "zod"; +import { eq } from "drizzle-orm"; +import { getDb } from "../../db/client.js"; +import { users } from "../../db/schema.js"; +import { HttpError } from "../../middleware/errorHandler.js"; + +const patchSchema = z.object({ + displayName: z.string().min(1).max(80).optional(), + avatarUrl: z.string().max(500_000).nullable().optional(), + username: z + .string() + .min(3) + .max(30) + .regex(/^[a-zA-Z0-9_.-]+$/) + .nullable() + .optional(), +}); + +function publicUser(u: typeof users.$inferSelect) { + return { + id: u.id, + email: u.email, + username: u.username, + displayName: u.displayName, + avatarUrl: u.avatarUrl, + }; +} + +export async function getMe(req: Request, res: Response) { + const db = getDb(); + const [user] = await db + .select() + .from(users) + .where(eq(users.id, req.userId!)) + .limit(1); + if (!user) throw new HttpError(404, "user_not_found"); + res.json({ user: publicUser(user) }); +} + +export async function patchMe(req: Request, res: Response) { + const db = getDb(); + const input = patchSchema.parse(req.body); + + if (input.username) { + const [clash] = await db + .select({ id: users.id }) + .from(users) + .where(eq(users.username, input.username)) + .limit(1); + if (clash && clash.id !== req.userId) { + throw new HttpError(409, "username_taken"); + } + } + + const [user] = await db + .update(users) + .set({ ...input, updatedAt: Date.now() }) + .where(eq(users.id, req.userId!)) + .returning(); + if (!user) throw new HttpError(404, "user_not_found"); + res.json({ user: publicUser(user) }); +} diff --git a/server/src/modules/users/routes.ts b/server/src/modules/users/routes.ts new file mode 100644 index 0000000..13e8d0f --- /dev/null +++ b/server/src/modules/users/routes.ts @@ -0,0 +1,9 @@ +import { Router } from "express"; +import { requireAuth } from "../../middleware/auth.js"; +import { asyncHandler } from "../../middleware/errorHandler.js"; +import * as controller from "./controller.js"; + +export const usersRouter = Router(); + +usersRouter.get("/me", requireAuth, asyncHandler(controller.getMe)); +usersRouter.patch("/me", requireAuth, asyncHandler(controller.patchMe)); diff --git a/server/src/smoke-test.ts b/server/src/smoke-test.ts new file mode 100644 index 0000000..cf5572f --- /dev/null +++ b/server/src/smoke-test.ts @@ -0,0 +1,174 @@ +import { randomUUID } from "node:crypto"; +import type { Server } from "node:http"; +import { createApp } from "./app.js"; +import { initDb } from "./db/client.js"; + +const BASE = "http://127.0.0.1:4599/api/v1"; +let passed = 0; +let failed = 0; + +function check(label: string, cond: boolean) { + if (cond) { + passed++; + console.log(` PASS ${label}`); + } else { + failed++; + console.error(` FAIL ${label}`); + } +} + +async function req( + method: string, + path: string, + body?: unknown, + token?: string, + cookie?: string, +) { + const headers: Record = { "content-type": "application/json" }; + if (token) headers.authorization = `Bearer ${token}`; + if (cookie) headers.cookie = cookie; + const res = await fetch(BASE + path, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + }); + const setCookie = res.headers.get("set-cookie") || undefined; + let json: any = null; + try { + json = await res.json(); + } catch { + /* no body */ + } + return { status: res.status, json, setCookie }; +} + +async function main() { + await initDb(); + const app = createApp(); + const server: Server = await new Promise((resolve) => { + const s = app.listen(4599, () => resolve(s)); + }); + + try { + // --- A. Register --- + const email = `u${Date.now()}@test.dev`; + const profileId = randomUUID(); + const reg = await req("POST", "/auth/register", { + email, + password: "supersecret", + displayName: "Alice", + desiredId: profileId, + }); + check("register returns 201", reg.status === 201); + check("register reuses profile id as account id", reg.json?.user?.id === profileId); + check("register returns access token", typeof reg.json?.accessToken === "string"); + check("register sets refresh cookie", !!reg.setCookie); + const tokenA = reg.json.accessToken; + + // --- B. Push data from "device A" --- + const locId = randomUUID(); + const playerId = randomUUID(); + const sessionId = randomUUID(); + const t0 = Date.now(); + const push1 = await req( + "POST", + "/sync", + { + locations: [ + { id: locId, name: "Maison", createdAt: t0, updatedAt: t0 }, + ], + players: [ + { id: playerId, name: "Bob", createdAt: t0, updatedAt: t0 }, + ], + sessions: [ + { + id: sessionId, + gameId: "skyjo", + dateStart: t0, + dateEnd: t0, + players: [{ id: playerId, name: "Bob" }], + rounds: [], + status: "finished", + options: {}, + winnerIds: [playerId], + locationId: locId, + updatedAt: t0, + }, + ], + }, + tokenA, + ); + check("push returns 200", push1.status === 200); + check("push acks location", push1.json?.applied?.locations?.[0]?.id === locId); + check("push ack has server updatedAt", typeof push1.json?.applied?.sessions?.[0]?.updatedAt === "number"); + + // --- C. Second device pulls everything from scratch --- + const login = await req("POST", "/auth/login", { + email, + password: "supersecret", + }); + check("login returns 200", login.status === 200); + const tokenB = login.json.accessToken; + + const pullB = await req("GET", "/sync?since=0", undefined, tokenB); + check("pull returns 200", pullB.status === 200); + check("device B pulls the location", pullB.json?.locations?.some((l: any) => l.id === locId)); + check("device B pulls the player", pullB.json?.players?.some((p: any) => p.id === playerId)); + check("device B pulls the session", pullB.json?.sessions?.some((s: any) => s.id === sessionId)); + check("pulled session keeps jsonb players", Array.isArray(pullB.json?.sessions?.[0]?.players)); + check("pull returns a server cursor", typeof pullB.json?.serverTime === "number"); + + // --- D. Conflict: both devices edit the same location, last push wins --- + await req("POST", "/sync", { + locations: [{ id: locId, name: "Maison A", updatedAt: Date.now() }], + }, tokenA); + await new Promise((r) => setTimeout(r, 5)); + await req("POST", "/sync", { + locations: [{ id: locId, name: "Maison B", updatedAt: Date.now() }], + }, tokenB); + + const pullFinal = await req("GET", "/sync?since=0", undefined, tokenA); + const finalLoc = pullFinal.json.locations.find((l: any) => l.id === locId); + check("conflict resolves last-write-wins (Maison B)", finalLoc?.name === "Maison B"); + + // --- E. Tombstone: soft delete propagates --- + await req("POST", "/sync", { + players: [{ id: playerId, name: "Bob", deletedAt: Date.now(), updatedAt: Date.now() }], + }, tokenA); + const pullDel = await req("GET", "/sync?since=0", undefined, tokenB); + const delPlayer = pullDel.json.players.find((p: any) => p.id === playerId); + check("tombstone is returned on pull", !!delPlayer?.deletedAt); + + // --- F. Incremental pull excludes old records --- + const cursor = pullFinal.json.serverTime; + await new Promise((r) => setTimeout(r, 5)); + const freshLoc = randomUUID(); + await req("POST", "/sync", { + locations: [{ id: freshLoc, name: "Nouveau", updatedAt: Date.now() }], + }, tokenA); + const pullSince = await req("GET", `/sync?since=${cursor}`, undefined, tokenB); + check("incremental pull includes new record", pullSince.json.locations.some((l: any) => l.id === freshLoc)); + + // --- G. Refresh token rotation --- + const cookie = reg.setCookie!.split(";")[0]; + const refreshed = await req("POST", "/auth/refresh", undefined, undefined, cookie); + check("refresh returns new access token", typeof refreshed.json?.accessToken === "string"); + // Old refresh token is now revoked (rotation) + const reuse = await req("POST", "/auth/refresh", undefined, undefined, cookie); + check("reusing rotated refresh token fails", reuse.status === 401); + + // --- H. Auth required on sync --- + const noAuth = await req("GET", "/sync?since=0"); + check("sync without token is 401", noAuth.status === 401); + } finally { + server.close(); + } + + console.log(`\n${passed} passed, ${failed} failed`); + process.exit(failed === 0 ? 0 : 1); +} + +main().catch((err) => { + console.error("Smoke test crashed:", err); + process.exit(1); +}); diff --git a/server/src/utils/jwt.ts b/server/src/utils/jwt.ts new file mode 100644 index 0000000..e8b9681 --- /dev/null +++ b/server/src/utils/jwt.ts @@ -0,0 +1,21 @@ +import jwt from "jsonwebtoken"; +import { env, ACCESS_TOKEN_TTL } from "../config/env.js"; + +interface AccessPayload { + sub: string; // user id +} + +export function signAccessToken(userId: string): string { + return jwt.sign({ sub: userId } satisfies AccessPayload, env.jwtSecret, { + expiresIn: ACCESS_TOKEN_TTL, + }); +} + +export function verifyAccessToken(token: string): string | null { + try { + const decoded = jwt.verify(token, env.jwtSecret) as AccessPayload; + return decoded.sub || null; + } catch { + return null; + } +} diff --git a/server/src/utils/password.ts b/server/src/utils/password.ts new file mode 100644 index 0000000..7315e32 --- /dev/null +++ b/server/src/utils/password.ts @@ -0,0 +1,15 @@ +import bcrypt from "bcryptjs"; + +// bcryptjs is pure-JS: no native build step, works out of the box in Alpine. +const ROUNDS = 10; + +export function hashPassword(password: string): Promise { + return bcrypt.hash(password, ROUNDS); +} + +export function verifyPassword( + password: string, + hash: string, +): Promise { + return bcrypt.compare(password, hash); +} diff --git a/server/src/utils/tokens.ts b/server/src/utils/tokens.ts new file mode 100644 index 0000000..29c9ff2 --- /dev/null +++ b/server/src/utils/tokens.ts @@ -0,0 +1,15 @@ +import { randomBytes, createHash, randomUUID } from "node:crypto"; + +// Opaque refresh tokens: a random secret is stored only as a hash server-side. +export function generateRefreshToken(): { token: string; hash: string } { + const token = randomBytes(48).toString("base64url"); + return { token, hash: hashToken(token) }; +} + +export function hashToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} + +export function newId(): string { + return randomUUID(); +} diff --git a/server/tsconfig.json b/server/tsconfig.json new file mode 100644 index 0000000..c6482d1 --- /dev/null +++ b/server/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "outDir": "dist", + "rootDir": "src", + "sourceMap": true, + "declaration": false, + "types": ["node"] + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/src/App.tsx b/src/App.tsx index 4a563d5..404a331 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,16 +8,29 @@ import PlayGame from "./pages/PlayGame"; import GameOver from "./pages/GameOver"; import History from "./pages/History"; import Players from "./pages/Players"; +import Locations from "./pages/Locations"; +import Profiles from "./pages/Profiles"; +import Auth from "./pages/Auth"; import Settings from "./pages/Settings"; import Statistics from "./pages/Stats"; import { useAppStore } from "./stores/appStore"; +import { useProfileStore } from "./stores/profileStore"; +import { useAuthStore } from "./stores/authStore"; +import { startSyncTriggers } from "./sync/syncEngine"; function App() { const { loadSettings } = useAppStore(); + const { loadProfiles } = useProfileStore(); + const restoreAuth = useAuthStore((s) => s.restore); useEffect(() => { + // Order matters: profiles must be loaded before auth restore triggers a sync. + loadProfiles().then(() => { + restoreAuth(); + startSyncTriggers(); + }); loadSettings(); - }, [loadSettings]); + }, [loadProfiles, loadSettings, restoreAuth]); return ( @@ -26,6 +39,9 @@ function App() { } /> } /> } /> + } /> + } /> + } /> } /> } /> diff --git a/src/components/NavigationMenu.tsx b/src/components/NavigationMenu.tsx index 0719c64..2dafee8 100644 --- a/src/components/NavigationMenu.tsx +++ b/src/components/NavigationMenu.tsx @@ -1,13 +1,26 @@ import { useState, useRef, useEffect } from "react"; -import { Menu, Home, History, Settings, Users, BarChart2 } from "lucide-react"; +import { + Menu, + Home, + History, + Settings, + Users, + BarChart2, + MapPin, + UserCircle, + ChevronRight, +} from "lucide-react"; import { useNavigate } from "react-router-dom"; import { motion, AnimatePresence } from "framer-motion"; import { Button } from "./ui/button"; +import { Avatar } from "./ui/avatar"; +import { useProfileStore } from "../stores/profileStore"; export function NavigationMenu() { const [isOpen, setIsOpen] = useState(false); const navigate = useNavigate(); const menuRef = useRef(null); + const activeProfile = useProfileStore((s) => s.activeProfile); useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -41,12 +54,32 @@ export function NavigationMenu() { animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.95 }} transition={{ duration: 0.15 }} - className="absolute top-full left-0 mt-2 w-56 bg-card border shadow-xl rounded-2xl overflow-hidden" + className="absolute top-full left-0 mt-2 w-60 bg-card border shadow-xl rounded-2xl overflow-hidden" >
+ @@ -62,12 +95,24 @@ export function NavigationMenu() { > Joueurs + + +

+ {mode === "login" ? "Connexion" : "Créer un compte"} +

+ + +

+ {mode === "login" + ? "Connectez-vous pour synchroniser vos parties entre vos appareils." + : `Créez un compte pour sauvegarder et synchroniser le profil « ${activeProfile?.name ?? ""} ».`} +

+ + + +
+
+ + + {errors.email && ( +

+ {errors.email.message} +

+ )} +
+ + {mode === "register" && ( +
+ + + {errors.username && ( +

+ {errors.username.message} +

+ )} +
+ )} + +
+ + + {errors.password && ( +

+ {errors.password.message} +

+ )} +
+ + {serverError && ( +

+ {serverError} +

+ )} + + +
+
+
+ + +
+ ); +} diff --git a/src/pages/GameOver.tsx b/src/pages/GameOver.tsx index 9ea942d..8387aba 100644 --- a/src/pages/GameOver.tsx +++ b/src/pages/GameOver.tsx @@ -1,7 +1,8 @@ import { useEffect, useState } from "react"; import { useParams, useNavigate } from "react-router-dom"; -import { Trophy, Home, Share2, RotateCcw, Clock } from "lucide-react"; +import { Trophy, Home, Share2, RotateCcw, Clock, MapPin } from "lucide-react"; import * as Icons from "lucide-react"; +import { useLiveQuery } from "dexie-react-hooks"; import { db } from "../database/db"; import { GameSession } from "../types"; import { getGameConfig } from "../games"; @@ -25,6 +26,12 @@ export default function GameOver() { const gameConfig = getGameConfig(session?.gameId || ""); useGameTheme(gameConfig); + const location = useLiveQuery( + () => + session?.locationId ? db.locations.get(session.locationId) : undefined, + [session?.locationId], + ); + useEffect(() => { if (sessionId) { db.sessions.get(sessionId).then((data) => { @@ -115,6 +122,8 @@ export default function GameOver() { gameConfig.id, newPlayers, session.options, + session.profileId, + session.locationId, ); navigate(`/play/${newSessionId}`); }; @@ -174,9 +183,17 @@ export default function GameOver() {

-
- - {durationInMinutes} minutes • {session.rounds.length} manches +
+
+ + {durationInMinutes} minutes • {session.rounds.length} manches +
+ {session.locationId && ( +
+ + {location?.name ?? "—"} +
+ )}
diff --git a/src/pages/History.tsx b/src/pages/History.tsx index 68f979d..795121b 100644 --- a/src/pages/History.tsx +++ b/src/pages/History.tsx @@ -4,6 +4,8 @@ import { db } from "../database/db"; import { games } from "../games"; import { Card, CardContent } from "../components/ui/card"; import { NavigationMenu } from "../components/NavigationMenu"; +import { useProfileStore } from "../stores/profileStore"; +import { MapPin } from "lucide-react"; import { motion } from "framer-motion"; @@ -18,9 +20,20 @@ function formatDate(ms: number) { export default function History() { const navigate = useNavigate(); - const sessions = useLiveQuery(() => - db.sessions.orderBy("dateStart").reverse().toArray(), + const activeProfileId = useProfileStore((s) => s.activeProfileId); + const sessions = useLiveQuery( + async () => { + if (!activeProfileId) return []; + const arr = await db.sessions + .where("profileId") + .equals(activeProfileId) + .and((s) => !s.deletedAt) + .sortBy("dateStart"); + return arr.reverse(); + }, + [activeProfileId], ); + const locations = useLiveQuery(() => db.locations.toArray()) || []; return (
@@ -40,6 +53,9 @@ export default function History() { {sessions.map((session, index) => { const game = games.find((g) => g.id === session.gameId); if (!game) return null; + const location = session.locationId + ? locations.find((l) => l.id === session.locationId) + : undefined; return (
-

- {formatDate(session.dateStart)} +

+ {formatDate(session.dateStart)} + {session.locationId && ( + + + {location?.name ?? "—"} + + )}

{session.players.map((p) => { diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 3c991ec..4f33dd0 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -5,6 +5,7 @@ import { games } from "../games"; import { db } from "../database/db"; import { motion } from "framer-motion"; import { NavigationMenu } from "../components/NavigationMenu"; +import { useProfileStore } from "../stores/profileStore"; // Kurzgesagt-style Planet Logo const PlanetLogo = () => ( @@ -31,10 +32,19 @@ const PlanetLogo = () => ( export default function Home() { const navigate = useNavigate(); + const activeProfileId = useProfileStore((s) => s.activeProfileId); - // Load unfinished games - const activeSessions = useLiveQuery(() => - db.sessions.where("status").equals("playing").toArray(), + // Load unfinished games for the active profile + const activeSessions = useLiveQuery( + async () => { + if (!activeProfileId) return []; + return db.sessions + .where("profileId") + .equals(activeProfileId) + .and((s) => s.status === "playing" && !s.deletedAt) + .toArray(); + }, + [activeProfileId], ); return ( diff --git a/src/pages/Locations.tsx b/src/pages/Locations.tsx new file mode 100644 index 0000000..8bfc119 --- /dev/null +++ b/src/pages/Locations.tsx @@ -0,0 +1,238 @@ +import { useState } from "react"; +import { useLiveQuery } from "dexie-react-hooks"; +import { db } from "../database/db"; +import { MapPin, Trash2, Edit2, Check, X, Plus, Home } from "lucide-react"; +import { Card, CardContent } from "../components/ui/card"; +import { Button } from "../components/ui/button"; +import { Input } from "../components/ui/input"; +import { NavigationMenu } from "../components/NavigationMenu"; +import { generateId } from "../utils/id"; +import { useProfileStore } from "../stores/profileStore"; +import { motion } from "framer-motion"; + +export default function Locations() { + const activeProfileId = useProfileStore((s) => s.activeProfileId); + const locations = useLiveQuery( + async () => { + if (!activeProfileId) return []; + return db.locations + .where("profileId") + .equals(activeProfileId) + .and((l) => !l.deletedAt) + .sortBy("name"); + }, + [activeProfileId], + ); + const [editingId, setEditingId] = useState(null); + const [editName, setEditName] = useState(""); + const [editAddress, setEditAddress] = useState(""); + const [isAdding, setIsAdding] = useState(false); + const [newName, setNewName] = useState(""); + const [newAddress, setNewAddress] = useState(""); + + const handleAdd = async () => { + const name = newName.trim(); + if (!name || !activeProfileId) return; + + const now = Date.now(); + await db.locations.add({ + id: generateId(), + name, + address: newAddress.trim() || undefined, + createdAt: now, + updatedAt: now, + profileId: activeProfileId, + }); + + setNewName(""); + setNewAddress(""); + setIsAdding(false); + }; + + const handleDelete = async (id: string, name: string) => { + if (window.confirm(`Êtes-vous sûr de vouloir supprimer "${name}" ?`)) { + // Soft-delete (tombstone) so the deletion can propagate to the server. + await db.locations.update(id, { deletedAt: Date.now() }); + } + }; + + const startEdit = (id: string, name: string, address?: string) => { + setEditingId(id); + setEditName(name); + setEditAddress(address ?? ""); + }; + + const saveEdit = async (id: string) => { + const name = editName.trim(); + if (name) { + await db.locations.update(id, { + name, + address: editAddress.trim() || undefined, + updatedAt: Date.now(), + }); + } + setEditingId(null); + }; + + return ( +
+
+ +

+ Emplacements +

+
+ + {isAdding ? ( + + + setNewName(e.target.value)} + placeholder="Nom (ex: Maison, Le Valet d'Or...)" + className="h-12" + autoFocus + /> + setNewAddress(e.target.value)} + placeholder="Adresse (optionnel)" + className="h-12" + /> +
+ + +
+
+
+ ) : ( + + )} + +
+ {!locations || locations.length === 0 ? ( +
+ +

Aucun emplacement enregistré.

+

+ Ajoutez votre maison ou vos enseignes de jeux favorites. +

+
+ ) : ( + locations.map((location, index) => ( + + +
+ + + {editingId === location.id ? ( +
+ setEditName(e.target.value)} + className="h-12" + autoFocus + /> + setEditAddress(e.target.value)} + placeholder="Adresse (optionnel)" + className="h-12" + /> +
+ + +
+
+ ) : ( + <> +
+
+ +
+
+
+ + {location.name} + + {location.address && ( + + {location.address} + + )} +
+
+ + +
+ + )} +
+ + + )) + )} +
+
+ ); +} diff --git a/src/pages/NewGame.tsx b/src/pages/NewGame.tsx index a2d8b9f..85ccd11 100644 --- a/src/pages/NewGame.tsx +++ b/src/pages/NewGame.tsx @@ -8,6 +8,7 @@ import { Users, GripVertical, BookOpen, + MapPin, } from "lucide-react"; import * as Icons from "lucide-react"; import { useLiveQuery } from "dexie-react-hooks"; @@ -15,6 +16,7 @@ import { Reorder, useDragControls } from "framer-motion"; import { db } from "../database/db"; import { getGameConfig } from "../games"; import { useGameStore } from "../stores/gameStore"; +import { useProfileStore } from "../stores/profileStore"; import { useGameTheme } from "../hooks/useGameTheme"; import { Player, SavedPlayer } from "../types"; import { Button } from "../components/ui/button"; @@ -82,15 +84,39 @@ export default function NewGame() { useGameTheme(gameConfig); const startNewGame = useGameStore((state) => state.startNewGame); + const activeProfileId = useProfileStore((state) => state.activeProfileId); const [players, setPlayers] = useState([ { id: generateId(), name: "" }, { id: generateId(), name: "" }, ]); const [options, setOptions] = useState>({}); + const [locationId, setLocationId] = useState(undefined); const savedPlayers = - useLiveQuery(() => db.players.orderBy("name").toArray()) || []; + useLiveQuery( + async () => { + if (!activeProfileId) return []; + return db.players + .where("profileId") + .equals(activeProfileId) + .and((p) => !p.deletedAt) + .sortBy("name"); + }, + [activeProfileId], + ) || []; + const locations = + useLiveQuery( + async () => { + if (!activeProfileId) return []; + return db.locations + .where("profileId") + .equals(activeProfileId) + .and((l) => !l.deletedAt) + .sortBy("name"); + }, + [activeProfileId], + ) || []; const availableSavedPlayers = savedPlayers.filter( (sp) => !players.some( @@ -143,6 +169,8 @@ export default function NewGame() { }; const handleStart = async () => { + if (!activeProfileId) return; + // Basic validation const validPlayers = players.filter((p) => p.name.trim() !== ""); if (validPlayers.length < gameConfig.minPlayers) { @@ -158,6 +186,7 @@ export default function NewGame() { const exists = await db.players .where("name") .equalsIgnoreCase(p.name) + .and((pl) => pl.profileId === activeProfileId && !pl.deletedAt) .first(); if (!exists) { @@ -176,16 +205,25 @@ export default function NewGame() { console.error("Failed to generate auto-avatar"); } } + const now = Date.now(); await db.players.add({ id: p.id, name: p.name, - createdAt: Date.now(), + createdAt: now, + updatedAt: now, avatar: finalAvatar, + profileId: activeProfileId, }); } } - const sessionId = await startNewGame(gameConfig.id, playersToSave, options); + const sessionId = await startNewGame( + gameConfig.id, + playersToSave, + options, + activeProfileId, + locationId, + ); navigate(`/play/${sessionId}`); }; @@ -323,6 +361,41 @@ export default function NewGame() { )} + {locations.length > 0 && ( +
+

+ Lieu +

+
+
setLocationId(undefined)} + > + + Aucun +
+ {locations.map((loc) => ( +
setLocationId(loc.id)} + > + + {loc.name} +
+ ))} +
+
+ )} + {gameConfig.options && gameConfig.options.length > 0 && (

diff --git a/src/pages/Players.tsx b/src/pages/Players.tsx index c70578d..52c14a0 100644 --- a/src/pages/Players.tsx +++ b/src/pages/Players.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { useLiveQuery } from "dexie-react-hooks"; import { db } from "../database/db"; +import { useProfileStore } from "../stores/profileStore"; import { Users, Trash2, @@ -20,14 +21,26 @@ import { resizeImage } from "../utils/image"; import { motion } from "framer-motion"; export default function Players() { - const players = useLiveQuery(() => db.players.orderBy("name").toArray()); + const activeProfileId = useProfileStore((s) => s.activeProfileId); + const players = useLiveQuery( + async () => { + if (!activeProfileId) return []; + return db.players + .where("profileId") + .equals(activeProfileId) + .and((p) => !p.deletedAt) + .sortBy("name"); + }, + [activeProfileId], + ); const [editingId, setEditingId] = useState(null); const [editName, setEditName] = useState(""); const [isGenerating, setIsGenerating] = useState(null); const handleDelete = async (id: string, name: string) => { if (window.confirm(`Êtes-vous sûr de vouloir supprimer ${name} ?`)) { - await db.players.delete(id); + // Soft-delete (tombstone) so the deletion can propagate to the server. + await db.players.update(id, { deletedAt: Date.now() }); } }; @@ -41,10 +54,16 @@ export default function Players() { const newName = editName.trim(); const playerToEdit = players?.find((p) => p.id === id); - await db.players.update(id, { name: newName }); + await db.players.update(id, { name: newName, updatedAt: Date.now() }); - // Update the player's name in all existing game sessions (history) - const sessions = await db.sessions.toArray(); + // Update the player's name in this profile's game sessions (history). + // Scoped by profile so a same-named player in another profile is untouched. + const sessions = activeProfileId + ? await db.sessions + .where("profileId") + .equals(activeProfileId) + .toArray() + : []; const updatedSessions = sessions .map((session) => { let hasChanges = false; @@ -75,10 +94,15 @@ export default function Players() { playerId: string, avatarData: string, ) => { - await db.players.update(playerId, { avatar: avatarData }); + await db.players.update(playerId, { + avatar: avatarData, + updatedAt: Date.now(), + }); // Update historical sessions too if we want the avatar to reflect immediately everywhere - const sessions = await db.sessions.toArray(); + const sessions = activeProfileId + ? await db.sessions.where("profileId").equals(activeProfileId).toArray() + : []; const updatedSessions = sessions .map((session) => { let hasChanges = false; diff --git a/src/pages/Profiles.tsx b/src/pages/Profiles.tsx new file mode 100644 index 0000000..90cd986 --- /dev/null +++ b/src/pages/Profiles.tsx @@ -0,0 +1,314 @@ +import { useState } from "react"; +import { useProfileStore } from "../stores/profileStore"; +import { + UserCircle, + Trash2, + Edit2, + Check, + X, + Camera, + Dices, + Loader2, + Plus, + CheckCircle2, +} from "lucide-react"; +import { Card, CardContent } from "../components/ui/card"; +import { Button } from "../components/ui/button"; +import { Input } from "../components/ui/input"; +import { NavigationMenu } from "../components/NavigationMenu"; +import { Avatar } from "../components/ui/avatar"; +import { resizeImage } from "../utils/image"; +import { motion } from "framer-motion"; + +export default function Profiles() { + const { + profiles, + activeProfileId, + switchProfile, + createProfile, + updateProfile, + deleteProfile, + } = useProfileStore(); + + const [editingId, setEditingId] = useState(null); + const [editName, setEditName] = useState(""); + const [isAdding, setIsAdding] = useState(false); + const [newName, setNewName] = useState(""); + const [isGenerating, setIsGenerating] = useState(null); + + const handleAdd = async () => { + const name = newName.trim(); + if (!name) return; + await createProfile(name); + setNewName(""); + setIsAdding(false); + }; + + const handleDelete = async (id: string, name: string) => { + if (profiles.length <= 1) { + alert("Vous devez conserver au moins un profil."); + return; + } + if ( + window.confirm( + `Supprimer le profil "${name}" ?\n\nToutes ses parties, joueurs et emplacements seront définitivement supprimés.`, + ) + ) { + await deleteProfile(id); + } + }; + + const startEdit = (id: string, name: string) => { + setEditingId(id); + setEditName(name); + }; + + const saveEdit = async (id: string) => { + if (editName.trim()) { + await updateProfile(id, { name: editName.trim() }); + } + setEditingId(null); + }; + + const handleAvatarChange = async ( + profileId: string, + event: React.ChangeEvent, + ) => { + const file = event.target.files?.[0]; + if (!file) return; + try { + const base64Image = await resizeImage(file); + await updateProfile(profileId, { avatar: base64Image }); + } catch (e) { + alert("Erreur lors de l'enregistrement de l'image."); + } + }; + + const handleGenerateRandomAvatar = async (profileId: string) => { + setIsGenerating(profileId); + try { + const seed = Math.random().toString(36).substring(7); + const url = `https://api.dicebear.com/9.x/bottts-neutral/svg?seed=${seed}`; + const response = await fetch(url); + const svgText = await response.text(); + const encodedSvg = `data:image/svg+xml;utf8,${encodeURIComponent(svgText)}`; + await updateProfile(profileId, { avatar: encodedSvg }); + } catch (e) { + alert( + "Impossible de générer l'avatar. Vérifiez votre connexion internet.", + ); + } finally { + setIsGenerating(null); + } + }; + + return ( +
+
+ +

+ Profils +

+
+ +

+ Chaque profil possède ses propres parties, joueurs et emplacements. + Touchez un profil pour l'activer. +

+ + {isAdding ? ( + + + setNewName(e.target.value)} + placeholder="Nom du profil" + className="h-12" + autoFocus + /> +
+ + +
+
+
+ ) : ( + + )} + +
+ {profiles.length === 0 ? ( +
+ +

Aucun profil.

+
+ ) : ( + profiles.map((profile, index) => { + const isActive = profile.id === activeProfileId; + return ( + + { + if (!isActive && editingId !== profile.id) { + switchProfile(profile.id); + } + }} + > + + {editingId === profile.id ? ( +
+ setEditName(e.target.value)} + className="h-12" + autoFocus + /> + + +
+ ) : ( + <> +
+
{ + e.stopPropagation(); + document + .getElementById(`profile-avatar-${profile.id}`) + ?.click(); + }} + > + +
+ +
+ + handleAvatarChange(profile.id, e) + } + /> +
+ +
+
+ + {profile.name} + + {isActive && ( + + + Profil actif + + )} +
+
+ + {profiles.length > 1 && ( + + )} +
+ + )} +
+
+
+ ); + }) + )} +
+
+ ); +} diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index b14b849..dd396bc 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -1,3 +1,5 @@ +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; import { Moon, Sun, @@ -6,9 +8,18 @@ import { Download, Upload, FileSpreadsheet, + LogIn, + LogOut, + RefreshCw, + Check, + CloudOff, } from "lucide-react"; import { useAppStore } from "../stores/appStore"; +import { useProfileStore } from "../stores/profileStore"; +import { useAuthStore } from "../stores/authStore"; +import { runSync, onSyncStatus, SyncStatus } from "../sync/syncEngine"; import { db } from "../database/db"; +import { generateId } from "../utils/id"; import { getGameConfig } from "../games"; import { calculatePlayerTotalScore } from "../utils/scoring"; import { Card, CardContent } from "../components/ui/card"; @@ -17,13 +28,26 @@ import { NavigationMenu } from "../components/NavigationMenu"; export default function Settings() { const { theme, setTheme } = useAppStore(); + const navigate = useNavigate(); + const { user, status, logout } = useAuthStore(); + const [syncStatus, setSyncStatus] = useState("idle"); + + useEffect(() => onSyncStatus(setSyncStatus), []); const handleExportJson = async () => { try { const sessions = await db.sessions.toArray(); const settings = await db.settings.toArray(); const players = await db.players.toArray(); - const data = JSON.stringify({ sessions, settings, players }); + const locations = await db.locations.toArray(); + const profiles = await db.profiles.toArray(); + const data = JSON.stringify({ + sessions, + settings, + players, + locations, + profiles, + }); const blob = new Blob([data], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); @@ -54,6 +78,31 @@ export default function Settings() { throw new Error("Fichier de sauvegarde invalide ou corrompu."); } + const locations = data.locations || []; + let profiles = data.profiles || []; + + // Rétrocompatibilité : un ancien backup n'a pas de profils. + // On crée un profil de secours et on y rattache les données héritées. + if (profiles.length === 0) { + const now = Date.now(); + const fallbackId = generateId(); + profiles = [ + { id: fallbackId, name: "Moi", createdAt: now, updatedAt: now }, + ]; + data.sessions.forEach((s: any) => { + if (!s.profileId) s.profileId = fallbackId; + }); + data.players.forEach((p: any) => { + if (!p.profileId) p.profileId = fallbackId; + }); + locations.forEach((l: any) => { + if (!l.profileId) l.profileId = fallbackId; + }); + data.settings.forEach((st: any) => { + if (!st.activeProfileId) st.activeProfileId = fallbackId; + }); + } + if ( window.confirm( "Attention : L'importation va écraser vos données actuelles. Voulez-vous continuer ?", @@ -61,13 +110,21 @@ export default function Settings() { ) { await db.transaction( "rw", - db.sessions, - db.settings, - db.players, + [ + db.sessions, + db.settings, + db.players, + db.locations, + db.profiles, + db.syncState, + ], async () => { await db.sessions.clear(); await db.settings.clear(); await db.players.clear(); + await db.locations.clear(); + await db.profiles.clear(); + await db.syncState.clear(); if (data.sessions.length > 0) await db.sessions.bulkAdd(data.sessions); @@ -75,6 +132,10 @@ export default function Settings() { await db.settings.bulkAdd(data.settings); if (data.players.length > 0) await db.players.bulkAdd(data.players); + if (locations.length > 0) + await db.locations.bulkAdd(locations); + if (profiles.length > 0) + await db.profiles.bulkAdd(profiles); }, ); @@ -152,10 +213,17 @@ export default function Settings() { const handleReset = async () => { if ( window.confirm( - "Êtes-vous sûr de vouloir supprimer TOUTES vos parties ? Cette action est irréversible.", + "Êtes-vous sûr de vouloir supprimer TOUTES les parties de ce profil ? Cette action est irréversible.", ) ) { - await db.sessions.clear(); + const activeProfileId = useProfileStore.getState().activeProfileId; + if (!activeProfileId) return; + // Soft-delete the active profile's sessions so the reset propagates on sync. + const now = Date.now(); + await db.sessions + .where("profileId") + .equals(activeProfileId) + .modify({ deletedAt: now }); alert("Données réinitialisées."); } }; @@ -169,6 +237,79 @@ export default function Settings() {

+
+

Compte

+ + + {status === "authenticated" && user ? ( +
+
+
+

+ {user.displayName} +

+

+ {user.email} +

+
+ + {syncStatus === "syncing" ? ( + <> + + Sync… + + ) : syncStatus === "error" ? ( + + + Erreur + + ) : ( + <> + + Synchronisé + + )} + +
+
+ + +
+
+ ) : ( +
+

+ Connectez-vous pour sauvegarder et synchroniser vos parties + entre vos appareils. +

+ +
+ )} +
+
+
+

Apparence

diff --git a/src/pages/Stats/index.tsx b/src/pages/Stats/index.tsx index 7764b86..a585e36 100644 --- a/src/pages/Stats/index.tsx +++ b/src/pages/Stats/index.tsx @@ -1,6 +1,7 @@ import { useState, useMemo } from "react"; import { useLiveQuery } from "dexie-react-hooks"; import { db } from "../../database/db"; +import { useProfileStore } from "../../stores/profileStore"; import { games } from "../../games"; import { Card, CardContent } from "../../components/ui/card"; import { Badge } from "../../components/ui/badge"; @@ -10,8 +11,31 @@ import { Avatar } from "../../components/ui/avatar"; import { motion } from "framer-motion"; export default function Statistics() { - const allSessions = useLiveQuery(() => db.sessions.toArray()) || []; - const players = useLiveQuery(() => db.players.toArray()) || []; + const activeProfileId = useProfileStore((s) => s.activeProfileId); + const allSessions = + useLiveQuery( + async () => { + if (!activeProfileId) return []; + return db.sessions + .where("profileId") + .equals(activeProfileId) + .and((s) => !s.deletedAt) + .toArray(); + }, + [activeProfileId], + ) || []; + const players = + useLiveQuery( + async () => { + if (!activeProfileId) return []; + return db.players + .where("profileId") + .equals(activeProfileId) + .and((p) => !p.deletedAt) + .toArray(); + }, + [activeProfileId], + ) || []; const [timeFilter, setTimeFilter] = useState<"all" | "7d" | "30d" | "year">( "all", diff --git a/src/stores/appStore.ts b/src/stores/appStore.ts index d35d6c8..4fdd660 100644 --- a/src/stores/appStore.ts +++ b/src/stores/appStore.ts @@ -27,7 +27,14 @@ export const useAppStore = create((set) => ({ if (settings) { await db.settings.put({ ...settings, theme }); } else { - await db.settings.add({ id: 1, theme, language: 'fr' }); + // Fallback (should not happen: populate always seeds settings + a profile) + const firstProfile = await db.profiles.orderBy('createdAt').first(); + await db.settings.add({ + id: 1, + theme, + language: 'fr', + activeProfileId: firstProfile?.id ?? '', + }); } } })); diff --git a/src/stores/authStore.ts b/src/stores/authStore.ts new file mode 100644 index 0000000..47989a8 --- /dev/null +++ b/src/stores/authStore.ts @@ -0,0 +1,117 @@ +import { create } from "zustand"; +import { + apiJson, + setAccessToken, + setOnUnauthorized, +} from "../lib/apiClient"; +import { useProfileStore } from "./profileStore"; +import { db } from "../database/db"; +import { runSync } from "../sync/syncEngine"; + +export interface AuthUser { + id: string; + email: string; + username: string | null; + displayName: string; + avatarUrl: string | null; +} + +type AuthStatus = "unknown" | "authenticated" | "anonymous"; + +interface AuthResponse { + user: AuthUser; + accessToken: string; +} + +interface AuthState { + user: AuthUser | null; + status: AuthStatus; + register: (input: { + email: string; + password: string; + displayName?: string; + username?: string; + }) => Promise; + login: (email: string, password: string) => Promise; + logout: () => Promise; + restore: () => Promise; +} + +async function linkActiveProfileToUser(userId: string) { + const profileId = useProfileStore.getState().activeProfileId; + if (profileId) { + await db.profiles.update(profileId, { remoteUserId: userId }); + await useProfileStore.getState().loadProfiles(); + } +} + +export const useAuthStore = create((set) => ({ + user: null, + status: "unknown", + + register: async ({ email, password, displayName, username }) => { + const profile = useProfileStore.getState().activeProfile; + const res = await apiJson( + "/auth/register", + { + method: "POST", + body: JSON.stringify({ + email, + password, + displayName: displayName || profile?.name || email, + username: username || undefined, + desiredId: profile?.id, + }), + }, + { auth: false }, + ); + setAccessToken(res.accessToken); + set({ user: res.user, status: "authenticated" }); + await linkActiveProfileToUser(res.user.id); + void runSync(); + }, + + login: async (email, password) => { + const res = await apiJson( + "/auth/login", + { method: "POST", body: JSON.stringify({ email, password }) }, + { auth: false }, + ); + setAccessToken(res.accessToken); + set({ user: res.user, status: "authenticated" }); + await linkActiveProfileToUser(res.user.id); + void runSync(); + }, + + logout: async () => { + try { + await apiJson("/auth/logout", { method: "POST" }, { auth: false }); + } catch { + /* ignore network errors on logout */ + } + setAccessToken(null); + set({ user: null, status: "anonymous" }); + }, + + // On boot: try to silently resume a session via the refresh cookie. + restore: async () => { + try { + const res = await apiJson( + "/auth/refresh", + { method: "POST" }, + { auth: false }, + ); + setAccessToken(res.accessToken); + set({ user: res.user, status: "authenticated" }); + void runSync(); + } catch { + set({ user: null, status: "anonymous" }); + } + }, +})); + +// If a refresh ultimately fails mid-request, drop back to anonymous. +setOnUnauthorized(() => { + setAccessToken(null); + useAuthStore.setState({ user: null, status: "anonymous" }); +}); diff --git a/src/stores/gameStore.ts b/src/stores/gameStore.ts index bc848b6..00d574c 100644 --- a/src/stores/gameStore.ts +++ b/src/stores/gameStore.ts @@ -14,6 +14,8 @@ interface GameState { gameId: string, players: Player[], options: Record, + profileId: string, + locationId?: string, ) => Promise; addRound: (scores: RoundScore[]) => Promise; updateRound: (roundId: string, scores: RoundScore[]) => Promise; @@ -37,19 +39,23 @@ export const useGameStore = create((set, get) => ({ saveSession: async () => { const { activeSession } = get(); if (activeSession) { - await db.sessions.put(activeSession); + await db.sessions.put({ ...activeSession, updatedAt: Date.now() }); } }, - startNewGame: async (gameId, players, options) => { + startNewGame: async (gameId, players, options, profileId, locationId) => { + const now = Date.now(); const newSession: GameSession = { id: generateId(), gameId, - dateStart: Date.now(), + dateStart: now, players, rounds: [], status: "playing", options, + locationId, + profileId, + updatedAt: now, }; await db.sessions.add(newSession); diff --git a/src/stores/profileStore.ts b/src/stores/profileStore.ts new file mode 100644 index 0000000..f826634 --- /dev/null +++ b/src/stores/profileStore.ts @@ -0,0 +1,133 @@ +import { create } from "zustand"; +import { Profile } from "../types"; +import { db } from "../database/db"; +import { generateId } from "../utils/id"; + +interface ProfileState { + activeProfileId: string | null; + activeProfile: Profile | null; + profiles: Profile[]; + loadProfiles: () => Promise; + switchProfile: (id: string) => Promise; + createProfile: (name: string, avatar?: string) => Promise; + updateProfile: ( + id: string, + changes: Partial>, + ) => Promise; + deleteProfile: (id: string) => Promise; +} + +async function ensureDefaultProfile(): Promise { + const now = Date.now(); + const profile: Profile = { + id: generateId(), + name: "Moi", + createdAt: now, + updatedAt: now, + }; + await db.profiles.add(profile); + return profile; +} + +async function setActiveProfileId(id: string) { + const settings = await db.settings.get(1); + if (settings) { + await db.settings.put({ ...settings, activeProfileId: id }); + } else { + await db.settings.add({ + id: 1, + theme: "system", + language: "fr", + activeProfileId: id, + }); + } +} + +export const useProfileStore = create((set, get) => ({ + activeProfileId: null, + activeProfile: null, + profiles: [], + + loadProfiles: async () => { + let profiles = await db.profiles.orderBy("createdAt").toArray(); + + // Defensive: guarantee at least one profile exists + if (profiles.length === 0) { + const created = await ensureDefaultProfile(); + profiles = [created]; + } + + const settings = await db.settings.get(1); + let activeId = settings?.activeProfileId; + + // Fallback if the stored active profile no longer exists + if (!activeId || !profiles.some((p) => p.id === activeId)) { + activeId = profiles[0].id; + await setActiveProfileId(activeId); + } + + const activeProfile = profiles.find((p) => p.id === activeId) || null; + set({ profiles, activeProfileId: activeId, activeProfile }); + }, + + switchProfile: async (id) => { + const profile = await db.profiles.get(id); + if (!profile) return; + await setActiveProfileId(id); + set({ activeProfileId: id, activeProfile: profile }); + }, + + createProfile: async (name, avatar) => { + const now = Date.now(); + const profile: Profile = { + id: generateId(), + name: name.trim(), + avatar, + createdAt: now, + updatedAt: now, + }; + await db.profiles.add(profile); + await get().loadProfiles(); + return profile; + }, + + updateProfile: async (id, changes) => { + await db.profiles.update(id, { ...changes, updatedAt: Date.now() }); + await get().loadProfiles(); + }, + + deleteProfile: async (id) => { + const { profiles, activeProfileId } = get(); + + // Never delete the last remaining profile + if (profiles.length <= 1) return; + + // Remove this profile and its local data from the device. This is a local + // removal (not a sync deletion), so we hard-delete without tombstones. + await db.transaction( + "rw", + db.profiles, + db.sessions, + db.players, + db.locations, + db.syncState, + async () => { + await db.sessions.where("profileId").equals(id).delete(); + await db.players.where("profileId").equals(id).delete(); + await db.locations.where("profileId").equals(id).delete(); + await db.syncState.delete(id); + await db.profiles.delete(id); + }, + ); + + // If we removed the active profile, switch to another one + if (activeProfileId === id) { + const remaining = await db.profiles.orderBy("createdAt").first(); + if (remaining) { + await setActiveProfileId(remaining.id); + } + } + + await get().loadProfiles(); + }, +})); diff --git a/src/sync/syncEngine.ts b/src/sync/syncEngine.ts new file mode 100644 index 0000000..bbf0138 --- /dev/null +++ b/src/sync/syncEngine.ts @@ -0,0 +1,217 @@ +import { db, remoteApply, localChange } from "../database/db"; +import { apiJson, getAccessToken } from "../lib/apiClient"; +import { useProfileStore } from "../stores/profileStore"; +import { GameSession, Location, SavedPlayer } from "../types"; + +interface PullResponse { + serverTime: number; + locations: any[]; + players: any[]; + sessions: any[]; +} + +interface PushResponse { + serverTime: number; + applied: { + locations: { id: string; updatedAt: number }[]; + players: { id: string; updatedAt: number }[]; + sessions: { id: string; updatedAt: number }[]; + }; +} + +let syncing = false; +const listeners = new Set<(state: SyncStatus) => void>(); + +export type SyncStatus = "idle" | "syncing" | "error"; +let currentStatus: SyncStatus = "idle"; + +export function onSyncStatus(cb: (state: SyncStatus) => void): () => void { + listeners.add(cb); + cb(currentStatus); + return () => listeners.delete(cb); +} + +function setStatus(s: SyncStatus) { + currentStatus = s; + listeners.forEach((cb) => cb(s)); +} + +// ---- Mapping: server rows -> local records (scoped to the active profile) ---- + +function toLocation(r: any, profileId: string): Location { + return { + id: r.id, + name: r.name, + address: r.address ?? undefined, + createdAt: Number(r.createdAt), + updatedAt: Number(r.updatedAt), + deletedAt: r.deletedAt ? Number(r.deletedAt) : undefined, + profileId, + dirty: 0, + }; +} + +function toPlayer(r: any, profileId: string): SavedPlayer { + return { + id: r.id, + name: r.name, + avatar: r.avatar ?? undefined, + createdAt: Number(r.createdAt), + updatedAt: Number(r.updatedAt), + deletedAt: r.deletedAt ? Number(r.deletedAt) : undefined, + profileId, + dirty: 0, + }; +} + +function toSession(r: any, profileId: string): GameSession { + return { + id: r.id, + gameId: r.gameId, + dateStart: Number(r.dateStart), + dateEnd: r.dateEnd ? Number(r.dateEnd) : undefined, + players: r.players ?? [], + rounds: r.rounds ?? [], + status: r.status, + options: r.options ?? {}, + winnerIds: r.winnerIds ?? undefined, + locationId: r.locationId ?? undefined, + updatedAt: Number(r.updatedAt), + deletedAt: r.deletedAt ? Number(r.deletedAt) : undefined, + profileId, + dirty: 0, + }; +} + +// ---- Push local (dirty) records, then clear their dirty flag on ack ---- + +async function pushDirty(profileId: string): Promise { + const [locations, players, sessions] = await Promise.all([ + db.locations + .where("profileId") + .equals(profileId) + .and((r) => r.dirty === 1) + .toArray(), + db.players + .where("profileId") + .equals(profileId) + .and((r) => r.dirty === 1) + .toArray(), + db.sessions + .where("profileId") + .equals(profileId) + .and((r) => r.dirty === 1) + .toArray(), + ]); + + if ( + locations.length === 0 && + players.length === 0 && + sessions.length === 0 + ) { + return; + } + + const res = await apiJson("/sync", { + method: "POST", + body: JSON.stringify({ locations, players, sessions }), + }); + + remoteApply.active = true; + try { + await db.transaction( + "rw", + db.locations, + db.players, + db.sessions, + async () => { + for (const a of res.applied.locations) + await db.locations.update(a.id, { updatedAt: a.updatedAt, dirty: 0 }); + for (const a of res.applied.players) + await db.players.update(a.id, { updatedAt: a.updatedAt, dirty: 0 }); + for (const a of res.applied.sessions) + await db.sessions.update(a.id, { updatedAt: a.updatedAt, dirty: 0 }); + }, + ); + } finally { + remoteApply.active = false; + } +} + +// ---- Pull remote changes since the stored cursor and apply them locally ---- + +async function pullSince(profileId: string): Promise { + const state = await db.syncState.get(profileId); + const since = state?.lastSyncedAt ?? 0; + + const res = await apiJson(`/sync?since=${since}`, { + method: "GET", + }); + + remoteApply.active = true; + try { + await db.transaction( + "rw", + db.locations, + db.players, + db.sessions, + db.syncState, + async () => { + for (const r of res.locations) + await db.locations.put(toLocation(r, profileId)); + for (const r of res.players) + await db.players.put(toPlayer(r, profileId)); + for (const r of res.sessions) + await db.sessions.put(toSession(r, profileId)); + await db.syncState.put({ profileId, lastSyncedAt: res.serverTime }); + }, + ); + } finally { + remoteApply.active = false; + } +} + +// ---- Public entry point ---- + +export async function runSync(): Promise { + if (syncing) return; + if (!getAccessToken()) return; // not logged in + const profileId = useProfileStore.getState().activeProfileId; + if (!profileId) return; + + syncing = true; + setStatus("syncing"); + try { + await pushDirty(profileId); + await pullSince(profileId); + setStatus("idle"); + } catch (err) { + console.error("[sync] failed", err); + setStatus("error"); + } finally { + syncing = false; + } +} + +// ---- Triggers: reconnection, tab focus, and a periodic heartbeat ---- + +let started = false; +let debounceTimer: ReturnType | null = null; + +// Push shortly after any local change (coalesces bursts of edits). +function scheduleSync() { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => void runSync(), 1500); +} + +export function startSyncTriggers() { + if (started) return; + started = true; + + localChange.notify = scheduleSync; + window.addEventListener("online", () => void runSync()); + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "visible") void runSync(); + }); + setInterval(() => void runSync(), 60_000); +} diff --git a/src/types/index.ts b/src/types/index.ts index 3c6f681..6aeac49 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -73,17 +73,53 @@ export interface GameSession { status: "playing" | "finished"; options: Record; winnerIds?: string[]; + locationId?: string; + profileId: string; + updatedAt: number; + deletedAt?: number; // tombstone pour la synchronisation + dirty?: number; // 1 = modifié localement, à pousser (0/absent = synchronisé) } export interface AppSettings { id: number; theme: "light" | "dark" | "system"; language: string; + activeProfileId: string; } export interface SavedPlayer { id: string; name: string; createdAt: number; + updatedAt: number; avatar?: string; + profileId: string; + linkedProfileId?: string; // réservé pour la Phase 4 (lien vers un ami) + deletedAt?: number; // tombstone pour la synchronisation + dirty?: number; // 1 = modifié localement, à pousser +} + +export interface Location { + id: string; + name: string; + address?: string; + createdAt: number; + updatedAt: number; + deletedAt?: number; // tombstone pour la synchronisation + profileId: string; + dirty?: number; // 1 = modifié localement, à pousser +} + +export interface SyncState { + profileId: string; + lastSyncedAt: number; +} + +export interface Profile { + id: string; + name: string; + avatar?: string; + createdAt: number; + updatedAt: number; + remoteUserId?: string; // réservé pour la Phase 3 (compte serveur) } diff --git a/vite.config.ts b/vite.config.ts index d8d86aa..aed96bf 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -61,6 +61,8 @@ export default defineConfig({ }, ], navigateFallback: "/index.html", + // Never let the SPA fallback swallow API calls. + navigateFallbackDenylist: [/^\/api/], cleanupOutdatedCaches: true, }, }), @@ -70,4 +72,14 @@ export default defineConfig({ "@": path.resolve(__dirname, "./src"), }, }, + server: { + // Dev: forward /api to the backend so the browser sees a single origin + // (required for the httpOnly refresh cookie to work end-to-end). + proxy: { + "/api": { + target: "http://localhost:3001", + changeOrigin: true, + }, + }, + }, });