diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e7a2406..6ddd7d96 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,10 @@ on: branches: [ "main" ] pull_request: branches: [ "main" ] + # Lets this workflow be run manually against any branch (Actions tab -> + # "Run workflow", or `gh workflow run`), without needing to push/PR to + # main first. Doesn't change push/pull_request behavior at all. + workflow_dispatch: {} jobs: build: @@ -12,6 +16,8 @@ jobs: steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Use Node.js 20 uses: actions/setup-node@v4 @@ -28,6 +34,10 @@ jobs: run: npm run build --if-present working-directory: ./middlewareNode + - name: Test middlewareNode + run: npm test -- --testTimeout=30000 + working-directory: ./middlewareNode + # chessServer - name: Install dependencies (chessServer) run: npm ci @@ -59,32 +69,35 @@ jobs: run: npm ci working-directory: ./react-ystemandchess - - name: Create environment file - run: | - mkdir -p src/core/environments - cat < src/core/environments/environment.ts - export const environment = { - production: false, - agora: { - appId: '${{ secrets.APP_ID }}', - }, - email: { - user: '${{ secrets.EMAIL_USER || '' }}', - pass: '${{ secrets.EMAIL_PASS || '' }}' - }, - urls: { - middlewareURL: '${{ secrets.MIDDLEWARE_URL }}', - stockFishURL: '${{ secrets.STOCKFISH_URL }}', - chessServer: '${{ secrets.CHESS_SERVER }}', - }, - }; - EOF - working-directory: ./react-ystemandchess - - name: Build react-ystemandchess run: CI=false npm run build --if-present working-directory: ./react-ystemandchess + env: + REACT_APP_MIDDLEWARE_URL: ${{ secrets.MIDDLEWARE_URL || 'https://ystemandchess.com/middleware' }} + REACT_APP_STOCKFISH_SERVER_URL: ${{ secrets.STOCKFISH_URL || 'https://ystemandchess.com/stockfishserver' }} + REACT_APP_CHESS_SERVER_URL: ${{ secrets.CHESS_SERVER || 'https://ystemandchess.com/chessserver' }} + REACT_APP_CHESS_CLIENT_URL: ${{ secrets.CHESS_CLIENT || 'https://ystemandchess.com/chessclient' }} + REACT_APP_AGORA_APP_ID: ${{ secrets.APP_ID || '' }} - name: Test react-ystemandchess - run: npm test + run: CI=true npm test -- --watchAll=false working-directory: ./react-ystemandchess + + - name: Verify PR commit authorship + if: github.event_name == 'pull_request' + run: | + BASE_SHA="${{ github.event.pull_request.base.sha }}" + HEAD_SHA="${{ github.event.pull_request.head.sha }}" + if [ -n "$BASE_SHA" ] && [ -n "$HEAD_SHA" ]; then + AUTHORS=$(git log --format='%an' "${BASE_SHA}..${HEAD_SHA}" | sort -u | grep -v '^$' || true) + else + AUTHORS=$(git log --format='%an' "origin/${{ github.base_ref }}..HEAD" | sort -u | grep -v '^$' || true) + fi + NORMALIZED_AUTHORS=$(echo "$AUTHORS" | sed 's/ToldYO/Ahmad Nakhala/' | sort -u | grep -v '^$' || true) + AUTHOR_COUNT=$(echo "$NORMALIZED_AUTHORS" | grep -v '^$' | wc -l) + echo "Commit authors on branch: $AUTHORS" + if [ "$AUTHOR_COUNT" -gt 1 ]; then + echo "ERROR: Branch contains commits by multiple distinct authors ($AUTHOR_COUNT). Base your branch on the upstream PR branch or wait for it to land on main to keep PR authorship clean." + exit 1 + fi + echo "PR authorship verified: clean single author ($NORMALIZED_AUTHORS)." diff --git a/chessServer/.env.example b/chessServer/.env.example new file mode 100644 index 00000000..4e25e7cc --- /dev/null +++ b/chessServer/.env.example @@ -0,0 +1,5 @@ +NODE_ENV=development +PORT=3001 +CORS_ORIGIN=http://localhost:3000,http://localhost:3002 +ALLOWED_ORIGINS= +MIDDLEWARE_URL=http://localhost:8000 diff --git a/chessServer/src/index.js b/chessServer/src/index.js index ea50058c..76f0c777 100644 --- a/chessServer/src/index.js +++ b/chessServer/src/index.js @@ -1,5 +1,8 @@ require("dotenv").config(); +const validateEnvironment = require("./validateEnvironment"); +validateEnvironment(); + const express = require("express"); const http = require("http"); const socketIo = require("socket.io"); @@ -13,28 +16,42 @@ const server = http.createServer(app); // Add logging functionaility to the server app.use(morgan("dev")); // dev -> preset format -const allowedOriginsSetting = process.env.CORS_ORIGIN || process.env.ALLOWED_ORIGINS; +const isProduction = process.env.NODE_ENV === "production"; + +const allowedOriginsSetting = + process.env.CORS_ORIGIN || process.env.ALLOWED_ORIGINS; + const allowedOrigins = allowedOriginsSetting ? allowedOriginsSetting.split(",").map((o) => o.trim()) : [ "https://ystemandchess.com", "https://www.ystemandchess.com", - "http://localhost:3000", - "http://localhost:3002", - "http://localhost:4200", + ...(isProduction + ? [] + : [ + "http://localhost:3000", + "http://localhost:3002", + "http://localhost:4200", + ]), ]; +const hasWildcard = allowedOrigins.includes("*"); + const corsOptions = { origin: function (origin, callback) { if (!origin) return callback(null, true); - if (allowedOrigins.indexOf(origin) !== -1 || allowedOrigins.includes("*")) { + if (hasWildcard) { + return callback(null, true); + } + if (allowedOrigins.indexOf(origin) !== -1) { callback(null, true); } else { - callback(new Error(`Origin ${origin} not allowed by CORS`)); + callback(null, false); } }, methods: ["GET", "POST"], - credentials: true, + // When wildcard is configured, disallow credentials to prevent unsafe CORS configuration + credentials: !hasWildcard, }; // Apply CORS middleware to handle cross-origin requests diff --git a/chessServer/src/tests/cors.test.js b/chessServer/src/tests/cors.test.js new file mode 100644 index 00000000..1a9393e8 --- /dev/null +++ b/chessServer/src/tests/cors.test.js @@ -0,0 +1,67 @@ +/** + * Verifies the CORS origin/credentials behavior in src/index.js. + * index.js can't be imported directly (it calls server.listen() as a + * module-load side effect), so this mirrors its corsOptions logic in an + * isolated app, the same approach the other tests in this file already use + * for their own standalone servers. Keep this in sync with src/index.js if + * that logic changes. + */ + +const express = require("express"); +const cors = require("cors"); +const request = require("supertest"); + +function buildApp(allowedOriginsCsv) { + const allowedOrigins = allowedOriginsCsv.split(",").map((o) => o.trim()); + const hasWildcard = allowedOrigins.includes("*"); + + const app = express(); + app.use( + cors({ + origin: function (origin, callback) { + if (!origin) return callback(null, true); + if (hasWildcard) { + return callback(null, true); + } + if (allowedOrigins.indexOf(origin) !== -1) { + callback(null, true); + } else { + callback(null, false); + } + }, + methods: ["GET", "POST"], + credentials: !hasWildcard, + }) + ); + app.get("/ping", (req, res) => res.json({ ok: true })); + return app; +} + +describe("chessServer CORS origin/credentials behavior", () => { + test("allowed origin (no wildcard): credentials allowed, header set to the origin", async () => { + const app = buildApp("https://ystemandchess.com,http://localhost:3000"); + const res = await request(app).get("/ping").set("Origin", "http://localhost:3000"); + + expect(res.status).toBe(200); + expect(res.headers["access-control-allow-origin"]).toBe("http://localhost:3000"); + expect(res.headers["access-control-allow-credentials"]).toBe("true"); + }); + + test("disallowed origin (no wildcard): rejected without a 500, no CORS header", async () => { + const app = buildApp("https://ystemandchess.com,http://localhost:3000"); + const res = await request(app).get("/ping").set("Origin", "https://evil.example.com"); + + expect(res.status).not.toBe(500); + expect(res.status).toBe(200); + expect(res.headers["access-control-allow-origin"]).toBeUndefined(); + }); + + test("wildcard configured: any origin allowed, but credentials are disabled", async () => { + const app = buildApp("*"); + const res = await request(app).get("/ping").set("Origin", "https://anything.example.com"); + + expect(res.status).toBe(200); + expect(res.headers["access-control-allow-origin"]).toBe("https://anything.example.com"); + expect(res.headers["access-control-allow-credentials"]).toBeUndefined(); + }); +}); diff --git a/chessServer/src/validateEnvironment.js b/chessServer/src/validateEnvironment.js new file mode 100644 index 00000000..14a9bdc7 --- /dev/null +++ b/chessServer/src/validateEnvironment.js @@ -0,0 +1,36 @@ +const REQUIRED_PRODUCTION_VARS = [ + "MIDDLEWARE_URL", +]; + +function hasCorsConfiguration() { + return Boolean( + process.env.CORS_ORIGIN?.trim() || + process.env.ALLOWED_ORIGINS?.trim() + ); +} + +function validateEnvironment() { + if (process.env.NODE_ENV !== "production") { + return; + } + + const missing = REQUIRED_PRODUCTION_VARS.filter((name) => { + const value = process.env[name]; + return !value || !value.trim(); + }); + + if (!hasCorsConfiguration()) { + missing.push("CORS_ORIGIN or ALLOWED_ORIGINS"); + } + + if (missing.length > 0) { + console.error( + `[chessServer] Missing required production environment variables: ${missing.join(", ")}` + ); + process.exit(1); + } + + console.log("[chessServer] Required production environment variables validated"); +} + +module.exports = validateEnvironment; diff --git a/deploy/dev/docker-compose.yml b/deploy/dev/docker-compose.yml deleted file mode 100644 index f21cf090..00000000 --- a/deploy/dev/docker-compose.yml +++ /dev/null @@ -1,53 +0,0 @@ -services: - apache: - image: httpd:2.4 - ports: - - "80:80" - volumes: - - ./httpd.conf:/usr/local/apache2/conf/httpd.conf:ro - depends_on: - - react-app - - middlewarenode - - chessserver - - stockfishserver - - react-app: - build: ../../react-ystemandchess - container_name: react-app - ports: - - "3000:3000" - environment: - - REACT_APP_CHESS_SERVER_URL=http://localhost:3001 - - REACT_APP_MIDDLEWARE_URL=http://localhost:8000 - - REACT_APP_STOCKFISH_URL=http://localhost:8080 - - middlewarenode: - build: ../../middlewareNode - container_name: middlewarenode - ports: - - "8000:8000" - environment: - - PORT=8000 - - SESSION_SECRET=dev-secret-replace-in-prod - # Analytics rate limit — requests per 15-minute window per IP (default: 100) - - ANALYTICS_RATE_LIMIT_MAX=100 - - chessserver: - build: ../../chessServer - container_name: chessserver - ports: - - "3001:3001" - environment: - - PORT=3001 - - MIDDLEWARE_URL=http://middlewarenode:8000 - - stockfishserver: - build: ../../stockfishServer - container_name: stockfishserver - ports: - - "9324:9324" - environment: - - PORT=9324 - -# Usage: docker-compose up --build -# Access at: http://localhost diff --git a/deploy/prod/docker-compose.yml b/deploy/prod/docker-compose.yml index e9bcf5e4..5a21e72a 100644 --- a/deploy/prod/docker-compose.yml +++ b/deploy/prod/docker-compose.yml @@ -1,17 +1,14 @@ -version: '3.4' - networks: ysc-net: - external: - name: ysc-net + name: ysc-net + external: true services: nginx: image: nginx:1.19.2-alpine container_name: reverse-proxy-server volumes: - - ./deploy/prod/nginx.conf:/etc/nginx/nginx.conf - - /home/azureuser/ysc-2/app.ystemandchess.com/YStemAndChess/dist_new/YStemAndChess:/var/www/html + - ./nginx.conf:/etc/nginx/nginx.conf - /etc/letsencrypt/live/ystemandchess.com/fullchain.pem:/etc/ysc-certs/ysc-cert.pem - /etc/letsencrypt/live/ystemandchess.com/privkey.pem:/etc/ysc-certs/ysc-key.pem - /etc/letsencrypt/options-ssl-nginx.conf:/etc/ysc-certs/options-ssl-nginx.conf @@ -29,20 +26,25 @@ services: - ystemandchess chessserver: - image: chessserver:${TAG} + image: chessserver:${TAG:-latest} container_name: chessserver environment: - - PORT=${PORT} + - NODE_ENV=production + - PORT=3001 + - MIDDLEWARE_URL=${MIDDLEWARE_URL} + - CORS_ORIGIN=${CORS_ORIGIN} networks: - ysc-net expose: - "3001" stockfishserver: - image: stockfishserver:${TAG} + image: stockfishserver:${TAG:-latest} container_name: stockfishserver environment: + - NODE_ENV=production - PORT=8080 + - CORS_ORIGIN=${CORS_ORIGIN} networks: - ysc-net expose: @@ -52,48 +54,42 @@ services: image: middlewarenode container_name: middleware environment: + - NODE_ENV=production - PORT=8000 - - indexKey=${indexKey} + - MONGO_URI=${MONGO_URI} + - INDEX_KEY=${INDEX_KEY} + - CORS_ORIGIN=${CORS_ORIGIN} - AZURE_STORAGE_ACCOUNT=${AZURE_STORAGE_ACCOUNT} - AZURE_STORAGE_KEY=${AZURE_STORAGE_KEY} - AZURE_STORAGE_CONTAINER=${AZURE_STORAGE_CONTAINER} - AZURE_STORAGE_REGION=${AZURE_STORAGE_REGION} - - mongoURI=${mongoURI} - - appID=${appID} - - auth=${auth} - - channel=${channel} - - uid=${uid} - - jwtSecret=${jwtSecret} - - NODE_ENV=${NODE_ENV} - - basepath=${basepath} - - clientId=${clientId} - - clientSecret=${clientSecret} - - redirectUri=${redirectUri} - - refreshToken=${refreshToken} - - user=${user} - - senderEmail=${senderEmail} + - AGORA_APP_ID=${AGORA_APP_ID} + - AGORA_UID=${AGORA_UID} + - AGORA_CUSTOMER_ID=${AGORA_CUSTOMER_ID} + - AGORA_CUSTOMER_CERT=${AGORA_CUSTOMER_CERT} + - BASEPATH=${BASEPATH} + - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID} + - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET} + - GOOGLE_REDIRECT_URI=${GOOGLE_REDIRECT_URI} + - GOOGLE_REFRESH_TOKEN=${GOOGLE_REFRESH_TOKEN} + - EMAIL_USER=${EMAIL_USER} + - SENDER_EMAIL=${SENDER_EMAIL} - SESSION_SECRET=${SESSION_SECRET} - ANALYTICS_RATE_LIMIT_MAX=${ANALYTICS_RATE_LIMIT_MAX:-100} + - LEADERBOARD_RATE_LIMIT_MAX=${LEADERBOARD_RATE_LIMIT_MAX:-60} networks: - ysc-net expose: - '8000' ystemandchess: - image: ystemandchess:${TAG} + image: ystemandchess:${TAG:-latest} container_name: ystemandchess - environment: - - agora:appID=${appID} - - urls:middlewareURL=52.249.251.163/middleware - - urls:stockFishURL=52.249.251.163/stockfishserver - - urls:chessServerURL=52.249.251.163/chessserver networks: - ysc-net expose: - - "80" + - "3000" depends_on: - middleware - stockfishserver - chessserver - volumes: - - ../YStemAndChess:/usr/src/app \ No newline at end of file diff --git a/deploy/prod/nginx.conf b/deploy/prod/nginx.conf index ee628adf..4ab81918 100644 --- a/deploy/prod/nginx.conf +++ b/deploy/prod/nginx.conf @@ -39,7 +39,7 @@ http { } upstream react_app { - server ystemandchess:80; + server ystemandchess:3000; } server { @@ -51,7 +51,7 @@ http { add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always; location / { - try_files $uri $uri/ /index.html =404; + proxy_pass http://react_app; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header Host $host; diff --git a/deploy/prod/tag_build_containers.sh b/deploy/prod/tag_build_containers.sh old mode 100644 new mode 100755 index fe00c20a..0f385e7a --- a/deploy/prod/tag_build_containers.sh +++ b/deploy/prod/tag_build_containers.sh @@ -1,52 +1,52 @@ #!/bin/bash -# Build Docker images for production deployment -# Usage: cd scripts && ./tag_build_containers.sh +set -e + +TAG=${TAG:-latest} echo "==========================================" echo "Building Docker images for PRODUCTION" +echo "TAG=$TAG" echo "==========================================" -echo "" -# Move to parent directory (where service folders are) -cd ../.. || exit 1 +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +cd "$REPO_ROOT" || exit 1 -services=(react-ystemandchess chessServer middlewareNode stockfishServer) +build_image() { + service_dir="$1" + image_name="$2" -for service in "${services[@]}" -do echo "==========================================" - echo "Building: $service" + echo "Building: $service_dir" + echo "Image: $image_name" echo "==========================================" - if [ ! -d "$service" ]; then - echo "ERROR: Directory $service not found!" + if [ ! -d "$service_dir" ]; then + echo "ERROR: Directory $service_dir not found!" exit 1 fi - cd $service || exit 1 - - # Convert to lowercase for image name - imagename=$(echo "$service" | awk '{ print tolower($0) }') - - echo "Image name: $imagename" + docker build -t "$image_name" "$service_dir" +} - # Build Docker image - docker build -t $imagename . || { - echo "ERROR: Failed to build $imagename" - cd .. - exit 1 - } +echo "==========================================" +echo "Building: react-ystemandchess" +echo "Image: ystemandchess:${TAG}" +echo "==========================================" - cd .. - echo "Successfully built $imagename" - echo "" -done +docker build \ + --build-arg REACT_APP_MIDDLEWARE_URL="${REACT_APP_MIDDLEWARE_URL}" \ + --build-arg REACT_APP_STOCKFISH_SERVER_URL="${REACT_APP_STOCKFISH_SERVER_URL}" \ + --build-arg REACT_APP_CHESS_SERVER_URL="${REACT_APP_CHESS_SERVER_URL}" \ + --build-arg REACT_APP_CHESS_CLIENT_URL="${REACT_APP_CHESS_CLIENT_URL}" \ + --build-arg REACT_APP_AGORA_APP_ID="${REACT_APP_AGORA_APP_ID}" \ + -t "ystemandchess:${TAG}" \ + react-ystemandchess +build_image "chessServer" "chessserver:${TAG}" +build_image "middlewareNode" "middlewarenode" +build_image "stockfishServer" "stockfishserver:${TAG}" echo "==========================================" echo "All production images built!" echo "==========================================" -echo "" -echo "To deploy:" -echo " cd scripts" -echo " docker-compose up -d" \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 8dcfd492..600cb80f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,14 @@ services: frontend: - build: ./react-ystemandchess + build: + context: ./react-ystemandchess + args: + REACT_APP_CHESS_SERVER_URL: http://localhost:3001 + REACT_APP_CHESS_CLIENT_URL: http://localhost:3002 + REACT_APP_MIDDLEWARE_URL: http://localhost:8000 + REACT_APP_STOCKFISH_SERVER_URL: http://localhost:8080 + REACT_APP_AGORA_APP_ID: "" container_name: frontend ports: - "3000:3000" diff --git a/middlewareNode/.env.example b/middlewareNode/.env.example new file mode 100644 index 00000000..6bd6f59a --- /dev/null +++ b/middlewareNode/.env.example @@ -0,0 +1,64 @@ +# Runtime environment +NODE_ENV=development +PORT=8000 + +# Required core backend configuration +MONGO_URI= +INDEX_KEY= +SESSION_SECRET= +CORS_ORIGIN=http://localhost:3000 + +# Feature-specific email / Google OAuth configuration +EMAIL_USER= +SENDER_EMAIL= +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GOOGLE_REDIRECT_URI= +GOOGLE_REFRESH_TOKEN= + +# Application base URL +BASEPATH=http://localhost:3000 + +# Azure storage +AZURE_STORAGE_ACCOUNT= +AZURE_STORAGE_KEY= +AZURE_STORAGE_CONTAINER= +AZURE_STORAGE_REGION= + +# Agora recording +AGORA_APP_ID= +AGORA_UID= +AGORA_CUSTOMER_ID= +AGORA_CUSTOMER_CERT= + +# API / route limits +ANALYTICS_RATE_LIMIT_MAX=100 +LEADERBOARD_RATE_LIMIT_MAX=60 +CHAT_RATE_LIMIT=30 + +# AI provider configuration +GEMINI_API_KEY= +GEMINI_BASE_URL= +GEMINI_MODEL= + +OPENAI_API_KEY= +OPENAI_BASE_URL= +OPENAI_MODEL= + +# Leaderboard scoring +LEADERBOARD_WEIGHT_TIME=1 +LEADERBOARD_WEIGHT_STREAK=5 +LEADERBOARD_WEIGHT_BADGE=10 +LEADERBOARD_WEIGHT_ACTIVITY=3 + +# PVP scoring +PVP_WEIGHT_WIN=3 +PVP_WEIGHT_DRAW=1 +PVP_WEIGHT_LOSS=0 + +# Script-only variable used by src/scripts/provisionAdmin.js +ADMIN_USERNAME= + +# Present in config mapping but not currently consumed by middleware source +JWT_SECRET= +EMAIL_PASS= diff --git a/middlewareNode/config/default.js b/middlewareNode/config/default.js new file mode 100644 index 00000000..93644608 --- /dev/null +++ b/middlewareNode/config/default.js @@ -0,0 +1,45 @@ +const crypto = require("crypto"); + +// For local development environments without an explicit INDEX_KEY / JWT_SECRET in .env, +// generate a random ephemeral key at boot so server startup and dev login succeed safely +// without committing a forgeable signing secret literal to version control. +const ephemeralSecret = crypto.randomBytes(32).toString("hex"); + +module.exports = { + mongoURI: process.env.MONGO_URI || "mongodb://localhost:27017/ystem_dev", + jwtSecret: process.env.JWT_SECRET || ephemeralSecret, + indexKey: process.env.INDEX_KEY || ephemeralSecret, + + corsOptions: { + origin: process.env.CORS_ORIGIN || "http://localhost:3000", + }, + + email: { + user: process.env.EMAIL_USER || "dev@example.com", + pass: process.env.EMAIL_PASS || "devpassword", + }, + + user: process.env.EMAIL_USER || "dev@example.com", + senderEmail: process.env.SENDER_EMAIL || "dev@example.com", + + clientId: process.env.GOOGLE_CLIENT_ID || "dev-client-id", + clientSecret: process.env.GOOGLE_CLIENT_SECRET || "dev-client-secret", + redirectUri: process.env.GOOGLE_REDIRECT_URI || "http://localhost:8000/auth/callback", + refreshToken: process.env.GOOGLE_REFRESH_TOKEN || "dev-refresh-token", + + basepath: process.env.BASEPATH || "http://localhost:3000", + + azureStorageAccount: process.env.AZURE_STORAGE_ACCOUNT || "", + azureStorageKey: process.env.AZURE_STORAGE_KEY || "", + azureContainer: process.env.AZURE_STORAGE_CONTAINER || "", + azureStorageRegion: process.env.AZURE_STORAGE_REGION || "1", + + appID: process.env.AGORA_APP_ID || "", + uid: process.env.AGORA_UID || "0", + customerId: process.env.AGORA_CUSTOMER_ID || "", + customerCertificate: process.env.AGORA_CUSTOMER_CERT || "", + + server: { + port: parseInt(process.env.PORT, 10) || 8000, + }, +}; diff --git a/middlewareNode/config/test.json b/middlewareNode/config/test.json new file mode 100644 index 00000000..a2e777f5 --- /dev/null +++ b/middlewareNode/config/test.json @@ -0,0 +1,30 @@ +{ + "mongoURI": "mongodb://localhost:27017/ystem_test", + "jwtSecret": "test-jwt-secret-key", + "indexKey": "test-jwt-secret-key", + "corsOptions": { + "origin": "http://localhost:3000" + }, + "email": { + "user": "test@example.com", + "pass": "testpassword" + }, + "user": "test@example.com", + "senderEmail": "test@example.com", + "clientId": "test-client-id", + "clientSecret": "test-client-secret", + "redirectUri": "http://localhost:8000/auth/callback", + "refreshToken": "test-refresh-token", + "basepath": "http://localhost:3000", + "azureStorageAccount": "", + "azureStorageKey": "", + "azureContainer": "", + "azureStorageRegion": "1", + "appID": "", + "uid": "0", + "customerId": "", + "customerCertificate": "", + "server": { + "port": 8000 + } +} diff --git a/middlewareNode/src/config/custom-environment-variables.json b/middlewareNode/src/config/custom-environment-variables.json deleted file mode 100644 index 73430b29..00000000 --- a/middlewareNode/src/config/custom-environment-variables.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "mongoURI": "MONGO_URI", - "jwtSecret": "JWT_SECRET", - "indexKey": "INDEX_KEY", - - "corsOptions": { "origin": "CORS_ORIGIN" }, - - "email": { - "user": "EMAIL_USER", - "pass": "EMAIL_PASS" - }, - - "user": "EMAIL_USER", - "senderEmail": "SENDER_EMAIL", - - "clientId": "GOOGLE_CLIENT_ID", - "clientSecret": "GOOGLE_CLIENT_SECRET", - "redirectUri": "GOOGLE_REDIRECT_URI", - "refreshToken": "GOOGLE_REFRESH_TOKEN", - - "basepath": "BASEPATH", - - "azureStorageAccount": "AZURE_STORAGE_ACCOUNT", - "azureStorageKey": "AZURE_STORAGE_KEY", - "azureContainer": "AZURE_STORAGE_CONTAINER", - "azureStorageRegion": "AZURE_STORAGE_REGION", - - "appID": "AGORA_APP_ID", - "uid": "AGORA_UID", - "customerId": "AGORA_CUSTOMER_ID", - "customerCertificate": "AGORA_CUSTOMER_CERT", - - "server": { "port": "PORT" } -} diff --git a/middlewareNode/src/config/db.js b/middlewareNode/src/config/db.js index b06145d0..80cff60b 100644 --- a/middlewareNode/src/config/db.js +++ b/middlewareNode/src/config/db.js @@ -249,19 +249,45 @@ async function ensureIndexes() { } } +const IS_PRODUCTION = process.env.NODE_ENV === "production"; + const connectDB = async () => { + try { + const target = new URL( + db.replace("mongodb+srv://", "https://").replace("mongodb://", "http://") + ); + console.log( + `[boot] env=${process.env.NODE_ENV || "undefined"} db_host=${target.hostname}${target.pathname}` + ); + } catch (_) { + console.log( + `[boot] env=${process.env.NODE_ENV || "undefined"} db_host=` + ); + } + try { console.log(`Connecting to MongoDB...`); await mongoose.connect(db, { useNewUrlParser: true, useUnifiedTopology: true, - serverSelectionTimeoutMS: 1000, // Timeout after 1 second instead of hanging + serverSelectionTimeoutMS: 1000, }); console.log("MongoDB Connected..."); await ensureIndexes(); - await seedTestUsers(); + + if (!IS_PRODUCTION) { + await seedTestUsers(); + } } catch (err) { console.warn(`Connection to configured MongoDB failed: ${err.message}`); + + if (IS_PRODUCTION) { + console.error( + "Refusing to fall back to in-memory MongoDB in production. Exiting." + ); + process.exit(1); + } + console.warn("Starting local in-memory MongoDB server as fallback..."); try { const { MongoMemoryServer } = require("mongodb-memory-server"); @@ -281,7 +307,7 @@ const connectDB = async () => { await seedTestUsers(); } catch (fallbackErr) { console.error("In-memory MongoDB startup failed:", fallbackErr.message); - process.exit(1); // Exit process if connection fails + process.exit(1); } } }; diff --git a/middlewareNode/src/config/validateEnvironment.js b/middlewareNode/src/config/validateEnvironment.js new file mode 100644 index 00000000..6d95446e --- /dev/null +++ b/middlewareNode/src/config/validateEnvironment.js @@ -0,0 +1,45 @@ +const REQUIRED_PRODUCTION_VARS = [ + "MONGO_URI", + "INDEX_KEY", + "CORS_ORIGIN", + "SESSION_SECRET", +]; + +function validateEnvironment() { + const isProd = process.env.NODE_ENV === "production"; + const isTest = process.env.NODE_ENV === "test"; + + if (isProd) { + const missing = REQUIRED_PRODUCTION_VARS.filter((name) => { + const value = process.env[name]; + return !value || !value.trim(); + }); + + if (missing.length > 0) { + console.error( + `[boot] Missing required production environment variables: ${missing.join(", ")}` + ); + process.exit(1); + } + console.log("[boot] Required production environment variables validated"); + } + + // Diagnostics and warnings across non-test environments + if (!isTest) { + const indexKey = (process.env.INDEX_KEY || "").trim(); + if (!indexKey && !isProd) { + console.warn( + "[boot] WARNING: INDEX_KEY is empty in environment. An ephemeral secret was generated for this session; JWT tokens will not persist across server restarts." + ); + } + + const aiKey = (process.env.GEMINI_API_KEY || process.env.OPENAI_API_KEY || "").trim(); + if (!aiKey) { + console.warn( + "[boot] NOTE: Neither GEMINI_API_KEY nor OPENAI_API_KEY is set. AI Tutor and Stockfish feedback routes will run in offline rule-based fallback mode." + ); + } + } +} + +module.exports = validateEnvironment; diff --git a/middlewareNode/src/routes/auth.js b/middlewareNode/src/routes/auth.js index 12ee37ac..6f4b8f81 100644 --- a/middlewareNode/src/routes/auth.js +++ b/middlewareNode/src/routes/auth.js @@ -88,9 +88,15 @@ router.post( } //Sign the jwt + const signingKey = + process.env.INDEX_KEY || + (config.has("indexKey") ? config.get("indexKey") : null) || + process.env.JWT_SECRET || + (config.has("jwtSecret") ? config.get("jwtSecret") : null); + jwt.sign( payload, - config.get("indexKey"), + signingKey, { expiresIn: 360000 }, function (err, token) { if (err) throw err; diff --git a/middlewareNode/src/server.js b/middlewareNode/src/server.js index 0f130a30..7c1a7dfa 100644 --- a/middlewareNode/src/server.js +++ b/middlewareNode/src/server.js @@ -1,4 +1,13 @@ // Main server configuration for the Node.js middleware API + +// Load local environment variables before importing modules that read config. +require("dotenv").config(); + +// Validate production configuration before db.js, passport.js, or other +// modules can call config.get(). +const validateEnvironment = require("./config/validateEnvironment"); +validateEnvironment(); + const express = require("express"); const session = require("express-session"); const connectDB = require("./config/db"); @@ -33,7 +42,24 @@ require("./scheduler/activitiesScheduler.js"); require("./scheduler/analyticsSummaryScheduler.js"); // Enable CORS for cross-origin requests -app.use(cors(config.get("corsOptions"))); +const configuredCorsOrigin = config.get("corsOptions.origin"); + +const allowedOrigins = String(configuredCorsOrigin || "") + .split(",") + .map((origin) => origin.trim()) + .filter(Boolean); + +app.use( + cors({ + origin(origin, callback) { + if (!origin || allowedOrigins.includes("*") || allowedOrigins.includes(origin)) { + return callback(null, true); + } + + return callback(null, false); + }, + }) +); // Connect to MongoDB database connectDB(); @@ -44,7 +70,11 @@ app.use(express.json({ extended: false })); // Configure session middleware app.use( session({ - secret: process.env.SESSION_SECRET || "dev-secret-change-in-prod", + secret: + process.env.SESSION_SECRET || + (process.env.NODE_ENV !== "production" + ? "dev-secret-change-in-prod" + : undefined), resave: false, saveUninitialized: false, }) diff --git a/middlewareNode/tests/cors.test.js b/middlewareNode/tests/cors.test.js new file mode 100644 index 00000000..f17afcd7 --- /dev/null +++ b/middlewareNode/tests/cors.test.js @@ -0,0 +1,55 @@ +/** + * Verifies the CORS origin-rejection behavior in src/server.js. + * server.js can't be imported directly (it calls connectDB()/app.listen() and + * requires SESSION_SECRET/Mongo at module load), so this mirrors its origin + * callback logic in an isolated app, the same approach already used by the + * chessServer/stockfishServer test suites. Keep this in sync with server.js + * if that logic changes. + */ + +const express = require("express"); +const cors = require("cors"); +const request = require("supertest"); + +function buildApp(allowedOriginsCsv) { + const allowedOrigins = String(allowedOriginsCsv || "") + .split(",") + .map((o) => o.trim()) + .filter(Boolean); + + const app = express(); + app.use( + cors({ + origin(origin, callback) { + if (!origin || allowedOrigins.includes("*") || allowedOrigins.includes(origin)) { + return callback(null, true); + } + return callback(null, false); + }, + }) + ); + app.get("/ping", (req, res) => res.json({ ok: true })); + return app; +} + +describe("CORS origin callback", () => { + const app = buildApp("https://ystemandchess.com,http://localhost:3000"); + + test("allowed origin gets the Access-Control-Allow-Origin header", async () => { + const res = await request(app).get("/ping").set("Origin", "http://localhost:3000"); + expect(res.status).toBe(200); + expect(res.headers["access-control-allow-origin"]).toBe("http://localhost:3000"); + }); + + test("disallowed origin is rejected without throwing a 500", async () => { + const res = await request(app).get("/ping").set("Origin", "https://evil.example.com"); + expect(res.status).not.toBe(500); + expect(res.status).toBe(200); + expect(res.headers["access-control-allow-origin"]).toBeUndefined(); + }); + + test("requests with no Origin header (curl, server-to-server) are unaffected", async () => { + const res = await request(app).get("/ping"); + expect(res.status).toBe(200); + }); +}); diff --git a/react-ystemandchess/Dockerfile b/react-ystemandchess/Dockerfile index bd8e975c..9e71c6d4 100644 --- a/react-ystemandchess/Dockerfile +++ b/react-ystemandchess/Dockerfile @@ -8,6 +8,18 @@ RUN npm install COPY . . +ARG REACT_APP_MIDDLEWARE_URL +ARG REACT_APP_STOCKFISH_SERVER_URL +ARG REACT_APP_CHESS_SERVER_URL +ARG REACT_APP_CHESS_CLIENT_URL +ARG REACT_APP_AGORA_APP_ID + +ENV REACT_APP_MIDDLEWARE_URL=$REACT_APP_MIDDLEWARE_URL +ENV REACT_APP_STOCKFISH_SERVER_URL=$REACT_APP_STOCKFISH_SERVER_URL +ENV REACT_APP_CHESS_SERVER_URL=$REACT_APP_CHESS_SERVER_URL +ENV REACT_APP_CHESS_CLIENT_URL=$REACT_APP_CHESS_CLIENT_URL +ENV REACT_APP_AGORA_APP_ID=$REACT_APP_AGORA_APP_ID + RUN npm run build FROM node:18.20.8-alpine @@ -23,4 +35,4 @@ EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=3s \ CMD node -e "require('http').get('http://localhost:3000', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})" -CMD ["serve", "-s", "build", "-l", "3000"] \ No newline at end of file +CMD ["serve", "-s", "build", "-l", "3000"] diff --git a/react-ystemandchess/src/App.tsx b/react-ystemandchess/src/App.tsx index 53281882..8275273e 100644 --- a/react-ystemandchess/src/App.tsx +++ b/react-ystemandchess/src/App.tsx @@ -1,6 +1,6 @@ import { useEffect } from "react"; import { BrowserRouter as Router } from "react-router-dom"; -import { environment } from "./environments/environment"; +import { environment } from "./environments"; import { useCookies } from "react-cookie"; import { SetPermissionLevel } from "./globals"; import NavBar from "./components/navbar/NavBar"; diff --git a/react-ystemandchess/src/Pages/Admin/Admin.tsx b/react-ystemandchess/src/Pages/Admin/Admin.tsx index 7ba570a5..80482e09 100644 --- a/react-ystemandchess/src/Pages/Admin/Admin.tsx +++ b/react-ystemandchess/src/Pages/Admin/Admin.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState } from "react"; import { useNavigate } from "react-router"; import { useCookies } from "react-cookie"; -import { environment } from "../../environments/environment"; +import { environment } from "../../environments"; const Admin = () => { const navigate = useNavigate(); // verify admin status diff --git a/react-ystemandchess/src/Pages/Admin/admin.test.tsx b/react-ystemandchess/src/Pages/Admin/admin.test.tsx index 41facd9e..ce7500a7 100644 --- a/react-ystemandchess/src/Pages/Admin/admin.test.tsx +++ b/react-ystemandchess/src/Pages/Admin/admin.test.tsx @@ -5,7 +5,7 @@ import { CookiesProvider } from "react-cookie"; import Admin from "./Admin"; // Mock environment -jest.mock("../../environments/environment", () => ({ +jest.mock("../../environments", () => ({ environment: { urls: { middlewareURL: "http://mock-api.com", diff --git a/react-ystemandchess/src/Pages/Analytics/ActivityFeed.tsx b/react-ystemandchess/src/Pages/Analytics/ActivityFeed.tsx index 4e57fd1f..1a28065d 100644 --- a/react-ystemandchess/src/Pages/Analytics/ActivityFeed.tsx +++ b/react-ystemandchess/src/Pages/Analytics/ActivityFeed.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState, useCallback } from 'react'; import { useCookies } from 'react-cookie'; -import { environment } from '../../environments/environment'; +import { environment } from '../../environments'; import LoadingSpinner from '../../components/Analytics/LoadingSpinner'; import ErrorBanner from '../../components/Analytics/ErrorBanner'; import { DateRange } from '../../components/Analytics/DateRangeFilter'; diff --git a/react-ystemandchess/src/Pages/Analytics/AnalyticsLayout.test.tsx b/react-ystemandchess/src/Pages/Analytics/AnalyticsLayout.test.tsx index c8769f39..c581d1aa 100644 --- a/react-ystemandchess/src/Pages/Analytics/AnalyticsLayout.test.tsx +++ b/react-ystemandchess/src/Pages/Analytics/AnalyticsLayout.test.tsx @@ -1,7 +1,7 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import AnalyticsLayout from "./AnalyticsLayout"; -jest.mock("../../environments/environment", () => ({ +jest.mock("../../environments", () => ({ environment: { urls: { middlewareURL: "http://mockurl.com" } }, })); diff --git a/react-ystemandchess/src/Pages/Analytics/GlobalView.tsx b/react-ystemandchess/src/Pages/Analytics/GlobalView.tsx index cbf4bd23..3755031b 100644 --- a/react-ystemandchess/src/Pages/Analytics/GlobalView.tsx +++ b/react-ystemandchess/src/Pages/Analytics/GlobalView.tsx @@ -10,7 +10,7 @@ import { Legend, } from 'chart.js'; import { Pie, Bar } from 'react-chartjs-2'; -import { environment } from '../../environments/environment'; +import { environment } from '../../environments'; import { DateRange } from '../../components/Analytics/DateRangeFilter'; import LoadingSpinner from '../../components/Analytics/LoadingSpinner'; import ErrorBanner from '../../components/Analytics/ErrorBanner'; diff --git a/react-ystemandchess/src/Pages/Analytics/IndividualView.tsx b/react-ystemandchess/src/Pages/Analytics/IndividualView.tsx index 4313eb2a..20ef0250 100644 --- a/react-ystemandchess/src/Pages/Analytics/IndividualView.tsx +++ b/react-ystemandchess/src/Pages/Analytics/IndividualView.tsx @@ -1,6 +1,6 @@ import React, { useState, useCallback } from 'react'; import { useCookies } from 'react-cookie'; -import { environment } from '../../environments/environment'; +import { environment } from '../../environments'; import { DateRange } from '../../components/Analytics/DateRangeFilter'; import LoadingSpinner from '../../components/Analytics/LoadingSpinner'; import ErrorBanner from '../../components/Analytics/ErrorBanner'; diff --git a/react-ystemandchess/src/Pages/Analytics/StudentTimeChart.tsx b/react-ystemandchess/src/Pages/Analytics/StudentTimeChart.tsx index eb2e0221..18eea828 100644 --- a/react-ystemandchess/src/Pages/Analytics/StudentTimeChart.tsx +++ b/react-ystemandchess/src/Pages/Analytics/StudentTimeChart.tsx @@ -1,6 +1,6 @@ import React, { useEffect } from 'react'; import { useCookies } from 'react-cookie'; -import { environment } from '../../environments/environment'; +import { environment } from '../../environments'; import StatsChart from '../../features/student/student-profile/StatsChart'; import LoadingSpinner from '../../components/Analytics/LoadingSpinner'; import ErrorBanner from '../../components/Analytics/ErrorBanner'; diff --git a/react-ystemandchess/src/Pages/Analytics/TrendChart.tsx b/react-ystemandchess/src/Pages/Analytics/TrendChart.tsx index 5379613c..1511760e 100644 --- a/react-ystemandchess/src/Pages/Analytics/TrendChart.tsx +++ b/react-ystemandchess/src/Pages/Analytics/TrendChart.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from 'react'; import { useCookies } from 'react-cookie'; -import { environment } from '../../environments/environment'; +import { environment } from '../../environments'; import StatsChart from '../../features/student/student-profile/StatsChart'; import LoadingSpinner from '../../components/Analytics/LoadingSpinner'; import ErrorBanner from '../../components/Analytics/ErrorBanner'; diff --git a/react-ystemandchess/src/Pages/Analytics/ZipcodeView.tsx b/react-ystemandchess/src/Pages/Analytics/ZipcodeView.tsx index 156da169..96f3356d 100644 --- a/react-ystemandchess/src/Pages/Analytics/ZipcodeView.tsx +++ b/react-ystemandchess/src/Pages/Analytics/ZipcodeView.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState, useCallback } from 'react'; import { useCookies } from 'react-cookie'; -import { environment } from '../../environments/environment'; +import { environment } from '../../environments'; import { DateRange } from '../../components/Analytics/DateRangeFilter'; import LoadingSpinner from '../../components/Analytics/LoadingSpinner'; import ErrorBanner from '../../components/Analytics/ErrorBanner'; diff --git a/react-ystemandchess/src/components/ChatWidget/ChatWidget.test.tsx b/react-ystemandchess/src/components/ChatWidget/ChatWidget.test.tsx new file mode 100644 index 00000000..88700ef9 --- /dev/null +++ b/react-ystemandchess/src/components/ChatWidget/ChatWidget.test.tsx @@ -0,0 +1,184 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { useCookies } from 'react-cookie'; +import ChatWidget from './ChatWidget'; + +const mockCookieToken = 'mock-jwt-test-token'; + +jest.mock('react-cookie', () => ({ + __esModule: true, + useCookies: jest.fn(), +})); + +jest.mock('../../environments', () => ({ + environment: { + urls: { + middlewareURL: 'http://localhost:8000', + }, + }, +})); + +describe('ChatWidget API Auth Headers', () => { + let originalFetch: typeof global.fetch; + + beforeEach(() => { + originalFetch = global.fetch; + (useCookies as jest.Mock).mockReturnValue([{ login: mockCookieToken }, jest.fn(), jest.fn()]); + }); + + afterEach(() => { + global.fetch = originalFetch; + jest.clearAllMocks(); + }); + + test('sends Authorization header when starting a session, sending messages, and ending a session', async () => { + const fetchMock = jest.fn().mockImplementation((url: string, options: any) => { + if (url.includes('/chat/session') && options?.method === 'POST' && !url.includes('/end')) { + return Promise.resolve({ + ok: true, + json: async () => ({ session: { _id: 'session-123', topic: 'General Coach' } }), + }); + } + if (url.includes('/chat/message') && options?.method === 'POST') { + const stream = new ReadableStream({ + start(controller) { + const encoder = new TextEncoder(); + controller.enqueue(encoder.encode('data: {"choices":[{"delta":{"content":"Hello learner!"}}]}\n\n')); + controller.enqueue(encoder.encode('data: [DONE]\n\n')); + controller.close(); + }, + }); + return Promise.resolve({ + ok: true, + body: stream, + }); + } + if (url.includes('/chat/session/session-123/end') && options?.method === 'POST') { + return Promise.resolve({ + ok: true, + json: async () => ({ + session: { + summary: 'Great session', + actions: ['Action 1'], + }, + }), + }); + } + return Promise.reject(new Error(`Unhandled URL: ${url}`)); + }); + + global.fetch = fetchMock as any; + + render(); + + // 1. Open the chat widget + const openBtn = screen.getByLabelText(/Talk to AI Tutor/i); + fireEvent.click(openBtn); + + // Verify /chat/session was called with Authorization header + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:8000/chat/session', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: `Bearer ${mockCookieToken}`, + 'Content-Type': 'application/json', + }), + }) + ); + }); + + // 2. Type and send a message + const input = await screen.findByPlaceholderText(/Reflect and reply here.../i); + fireEvent.change(input, { target: { value: 'How can I improve my endgame?' } }); + const sendBtn = screen.getByRole('button', { name: /Send/i }); + fireEvent.click(sendBtn); + + // Verify /chat/message was called with Authorization header + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:8000/chat/message', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: `Bearer ${mockCookieToken}`, + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ + sessionId: 'session-123', + message: 'How can I improve my endgame?', + }), + }) + ); + }); + + // 3. End session + const endBtn = await screen.findByTitle(/End Session/i); + fireEvent.click(endBtn); + + // Verify /chat/session/session-123/end was called with Authorization header + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:8000/chat/session/session-123/end', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: `Bearer ${mockCookieToken}`, + }), + }) + ); + }); + }); + + test('sends Authorization header when changing topic during an active session', async () => { + const fetchMock = jest.fn().mockImplementation((url: string, options: any) => { + if (url.includes('/chat/session/session-old/end') && options?.method === 'POST') { + return Promise.resolve({ + ok: true, + json: async () => ({ session: { summary: 'Done', actions: [] } }), + }); + } + if (url.includes('/chat/session') && options?.method === 'POST') { + return Promise.resolve({ + ok: true, + json: async () => ({ session: { _id: 'session-new', topic: 'Math Problem' } }), + }); + } + return Promise.resolve({ ok: true, json: async () => ({}) }); + }); + + global.fetch = fetchMock as any; + + render(); + + // Open chat + const openBtn = screen.getByLabelText(/Talk to AI Tutor/i); + fireEvent.click(openBtn); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:8000/chat/session', + expect.anything() + ); + }); + + // Select new topic + const dropdown = screen.getByRole('combobox'); + fireEvent.change(dropdown, { target: { value: 'math tutoring' } }); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:8000/chat/session', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: `Bearer ${mockCookieToken}`, + }), + body: JSON.stringify({ topic: 'math tutoring' }), + }) + ); + }); + }); +}); diff --git a/react-ystemandchess/src/components/ChatWidget/ChatWidget.tsx b/react-ystemandchess/src/components/ChatWidget/ChatWidget.tsx index ccaaa192..9b0b54cf 100644 --- a/react-ystemandchess/src/components/ChatWidget/ChatWidget.tsx +++ b/react-ystemandchess/src/components/ChatWidget/ChatWidget.tsx @@ -1,6 +1,6 @@ import React, { useState, useRef, useEffect } from 'react'; import './ChatWidget.scss'; -import { environment } from '../../environments/environment'; +import { environment } from '../../environments'; import { useCookies } from 'react-cookie'; import { CoachMascot, CoachExpression } from '../animations/CoachMascot/CoachMascot'; @@ -116,31 +116,13 @@ const ChatWidget = () => { setTopic(selectedTopic); setCoachExpression('thinking'); try { - let resolvedUserId = "5f8f8c44b54764421b7156e0"; // Static fallback - if (cookies.login) { - try { - const payload = JSON.parse(atob(cookies.login.split('.')[1])); - const username = payload.username; - if (username) { - const userRes = await fetch(`${environment.urls.middlewareURL}/user/getUser?username=${encodeURIComponent(username)}`, { - headers: { 'Authorization': `Bearer ${cookies.login}` } - }); - if (userRes.ok) { - const userData = await userRes.json(); - if (userData && userData._id) { - resolvedUserId = userData._id; - } - } - } - } catch (jwtErr) { - console.error("Failed to decode token or resolve user ID:", jwtErr); - } - } - const response = await fetch(`${environment.urls.middlewareURL}/chat/session`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ userId: resolvedUserId, topic: selectedTopic }) + headers: { + 'Content-Type': 'application/json', + ...(cookies.login ? { Authorization: `Bearer ${cookies.login}` } : {}), + }, + body: JSON.stringify({ topic: selectedTopic }) }); if (response.ok) { const data = await response.json(); @@ -182,7 +164,10 @@ const ChatWidget = () => { setCoachExpression('thinking'); try { const response = await fetch(`${environment.urls.middlewareURL}/chat/session/${sessionId}/end`, { - method: 'POST' + method: 'POST', + headers: { + ...(cookies.login ? { Authorization: `Bearer ${cookies.login}` } : {}), + } }); if (response.ok) { const data = await response.json(); @@ -209,7 +194,10 @@ const ChatWidget = () => { if (sessionId) { try { await fetch(`${environment.urls.middlewareURL}/chat/session/${sessionId}/end`, { - method: 'POST' + method: 'POST', + headers: { + ...(cookies.login ? { Authorization: `Bearer ${cookies.login}` } : {}), + } }); } catch (err) { console.error("Error ending previous session on topic change:", err); @@ -233,7 +221,8 @@ const ChatWidget = () => { const response = await fetch(`${environment.urls.middlewareURL}/chat/message`, { method: 'POST', headers: { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', + ...(cookies.login ? { Authorization: `Bearer ${cookies.login}` } : {}), }, body: JSON.stringify({ sessionId, message: userMsg }) }); diff --git a/react-ystemandchess/src/core/hooks/useAnalyticsApi.test.ts b/react-ystemandchess/src/core/hooks/useAnalyticsApi.test.ts index 582e7666..495a8fab 100644 --- a/react-ystemandchess/src/core/hooks/useAnalyticsApi.test.ts +++ b/react-ystemandchess/src/core/hooks/useAnalyticsApi.test.ts @@ -1,7 +1,7 @@ import { renderHook, waitFor } from "@testing-library/react"; import { useAnalyticsApi } from "./useAnalyticsApi"; -jest.mock("../../environments/environment", () => ({ +jest.mock("../../environments", () => ({ environment: { urls: { middlewareURL: "http://mockurl.com" } }, })); diff --git a/react-ystemandchess/src/core/hooks/useAnalyticsApi.ts b/react-ystemandchess/src/core/hooks/useAnalyticsApi.ts index b7ec88c7..262652e8 100644 --- a/react-ystemandchess/src/core/hooks/useAnalyticsApi.ts +++ b/react-ystemandchess/src/core/hooks/useAnalyticsApi.ts @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback } from 'react'; import { useCookies } from 'react-cookie'; -import { environment } from '../../environments/environment'; +import { environment } from '../../environments'; export interface AnalyticsParams { from?: string; diff --git a/react-ystemandchess/src/core/services/badgesApi.ts b/react-ystemandchess/src/core/services/badgesApi.ts index 99a96f8c..57c044ad 100644 --- a/react-ystemandchess/src/core/services/badgesApi.ts +++ b/react-ystemandchess/src/core/services/badgesApi.ts @@ -9,7 +9,7 @@ * - getUserBadges: Fetches badges earned by a specific user */ -import { environment } from "../../environments/environment"; +import { environment } from "../../environments"; /** * Fetches the complete catalog of all available badges * diff --git a/react-ystemandchess/src/environments/environment.js b/react-ystemandchess/src/environments/environment.js index bbdeb1fc..ff4df872 100644 --- a/react-ystemandchess/src/environments/environment.js +++ b/react-ystemandchess/src/environments/environment.js @@ -1,12 +1,13 @@ export const environment = { production: false, agora: { - appId: '6b7772f2a76f406192d8167460181be0', + appId: process.env.REACT_APP_AGORA_APP_ID || '6b7772f2a76f406192d8167460181be0', }, urls: { - middlewareURL: 'http://localhost:8000', - stockfishServerURL: 'http://localhost:8080', // unified naming - chessServerURL: 'http://localhost:8080', // point to mock stockfish server for testing - }, + middlewareURL: process.env.REACT_APP_MIDDLEWARE_URL || 'http://localhost:8000', + chessServerURL: process.env.REACT_APP_CHESS_SERVER_URL || 'http://localhost:3001', + chessClientURL: process.env.REACT_APP_CHESS_CLIENT_URL || 'http://localhost:3002', + stockfishServerURL: process.env.REACT_APP_STOCKFISH_SERVER_URL || 'http://localhost:8080', + }, productionType: 'development', // development/production }; \ No newline at end of file diff --git a/react-ystemandchess/src/environments/environment.prod.js b/react-ystemandchess/src/environments/environment.prod.js index 0f1a1460..d0bf16a3 100644 --- a/react-ystemandchess/src/environments/environment.prod.js +++ b/react-ystemandchess/src/environments/environment.prod.js @@ -1,12 +1,29 @@ +// Production environment config. +// +// Create React App injects REACT_APP_* variables at build time. +// Production builds must provide all service URLs explicitly. + +const requiredProductionEnv = (name) => { + const value = process.env[name]; + + if (process.env.NODE_ENV === 'production' && !value) { + throw new Error(`Missing required production environment variable: ${name}`); + } + + return value || ''; +}; + export const environment = { - production: false, + production: true, agora: { - appId: '6c368b93b82a4b3e9fb8e57da830f2a4', + appId: process.env.REACT_APP_AGORA_APP_ID || '', }, urls: { - middlewareURL: 'http://localhost/middleware/', - stockfishServerURL: 'http://localhost/stockfishserver/', - chessClientURL: 'http://localhost/chessclient/', - chessServer: 'http://localhost/chessserver/', + // No trailing slash. Consumers append their own route paths. + middlewareURL: requiredProductionEnv('REACT_APP_MIDDLEWARE_URL'), + stockfishServerURL: requiredProductionEnv('REACT_APP_STOCKFISH_SERVER_URL'), + chessServerURL: requiredProductionEnv('REACT_APP_CHESS_SERVER_URL'), + chessClientURL: requiredProductionEnv('REACT_APP_CHESS_CLIENT_URL'), }, -}; \ No newline at end of file + productionType: 'production', +}; diff --git a/react-ystemandchess/src/environments/environment.test.js b/react-ystemandchess/src/environments/environment.test.js new file mode 100644 index 00000000..91ef1aca --- /dev/null +++ b/react-ystemandchess/src/environments/environment.test.js @@ -0,0 +1,60 @@ +const fs = require('fs'); +const path = require('path'); +const { environment: dev } = require('./environment'); + +const PORTS = { + middlewareURL: 8000, + chessServerURL: 3001, + chessClientURL: 3002, + stockfishServerURL: 8080, +}; + +describe('Environment Configuration Invariants', () => { + test.each(Object.entries(PORTS))('dev %s uses port %i', (key, port) => { + expect(dev.urls[key]).toBe(`http://localhost:${port}`); + }); + + test('no temporary markers in committed development config', () => { + const src = fs.readFileSync(path.join(__dirname, 'environment.js'), 'utf8'); + expect(src).not.toMatch(/for testing|TEMP|TODO|mock|XXX/i); + }); + + test('production environment throws in production mode if variables are missing', () => { + const originalEnv = process.env.NODE_ENV; + try { + process.env.NODE_ENV = 'production'; + delete process.env.REACT_APP_MIDDLEWARE_URL; + delete process.env.REACT_APP_STOCKFISH_SERVER_URL; + delete process.env.REACT_APP_CHESS_SERVER_URL; + delete process.env.REACT_APP_CHESS_CLIENT_URL; + + jest.resetModules(); + expect(() => { + require('./environment.prod'); + }).toThrow(/Missing required production environment variable/); + } finally { + process.env.NODE_ENV = originalEnv; + } + }); + + test('production environment resolves properly when all variables are present', () => { + const originalEnv = process.env.NODE_ENV; + try { + process.env.NODE_ENV = 'production'; + process.env.REACT_APP_MIDDLEWARE_URL = 'https://ystemandchess.com/middleware'; + process.env.REACT_APP_STOCKFISH_SERVER_URL = 'https://ystemandchess.com/stockfishserver'; + process.env.REACT_APP_CHESS_SERVER_URL = 'https://ystemandchess.com/chessserver'; + process.env.REACT_APP_CHESS_CLIENT_URL = 'https://ystemandchess.com/chessclient'; + + jest.resetModules(); + const { environment: prod } = require('./environment.prod'); + expect(prod.production).toBe(true); + expect(prod.urls.middlewareURL).toBe('https://ystemandchess.com/middleware'); + expect(prod.urls.chessServerURL).toBe('https://ystemandchess.com/chessserver'); + expect(prod.urls.chessClientURL).toBe('https://ystemandchess.com/chessclient'); + expect(prod.urls.stockfishServerURL).toBe('https://ystemandchess.com/stockfishserver'); + } finally { + process.env.NODE_ENV = originalEnv; + } + }); +}); diff --git a/react-ystemandchess/src/features/admin/AdminProfile.tsx b/react-ystemandchess/src/features/admin/AdminProfile.tsx index a60ace1b..2afd1a76 100644 --- a/react-ystemandchess/src/features/admin/AdminProfile.tsx +++ b/react-ystemandchess/src/features/admin/AdminProfile.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from "react"; import { useCookies } from "react-cookie"; import { useNavigate } from "react-router"; import { SetPermissionLevel } from "../../globals"; -import { environment } from "../../environments/environment"; +import { environment } from "../../environments"; import userPortraitImg from "../../assets/images/user-portrait-placeholder.svg"; interface Template { diff --git a/react-ystemandchess/src/features/auth/login/Login.tsx b/react-ystemandchess/src/features/auth/login/Login.tsx index 507a1cac..66f5970b 100644 --- a/react-ystemandchess/src/features/auth/login/Login.tsx +++ b/react-ystemandchess/src/features/auth/login/Login.tsx @@ -1,6 +1,6 @@ import React from "react"; import { useState } from 'react'; -import { environment } from "../../../environments/environment"; +import { environment } from "../../../environments"; import { useCookies } from 'react-cookie'; import stemmy from "../../../assets/images/StreakProgressAssets/stemmy.svg"; import stemette from "../../../assets/images/StreakProgressAssets/stemette.svg"; diff --git a/react-ystemandchess/src/features/auth/reset-password/Reset-Password/resetPasswordService.ts b/react-ystemandchess/src/features/auth/reset-password/Reset-Password/resetPasswordService.ts index b333dc95..64469204 100644 --- a/react-ystemandchess/src/features/auth/reset-password/Reset-Password/resetPasswordService.ts +++ b/react-ystemandchess/src/features/auth/reset-password/Reset-Password/resetPasswordService.ts @@ -5,7 +5,7 @@ * Uses Nodemailer with Gmail to deliver reset links. */ -import { environment } from "../../../../environments/environment"; +import { environment } from "../../../../environments"; const nodemailer = require('nodemailer'); // Configure email transporter with Gmail credentials diff --git a/react-ystemandchess/src/features/auth/signup/AddChild.tsx b/react-ystemandchess/src/features/auth/signup/AddChild.tsx index 7f555e21..31f55ab1 100644 --- a/react-ystemandchess/src/features/auth/signup/AddChild.tsx +++ b/react-ystemandchess/src/features/auth/signup/AddChild.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { useCookies } from "react-cookie"; -import { environment } from "../../../environments/environment"; +import { environment } from "../../../environments"; import AuthLayout from "./AuthLayout"; import stemette from "../../../assets/images/StreakProgressAssets/stemette.svg"; diff --git a/react-ystemandchess/src/features/auth/signup/MentorSignUp.tsx b/react-ystemandchess/src/features/auth/signup/MentorSignUp.tsx index 5dc1b3ad..31d058cf 100644 --- a/react-ystemandchess/src/features/auth/signup/MentorSignUp.tsx +++ b/react-ystemandchess/src/features/auth/signup/MentorSignUp.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { useNavigate } from "react-router-dom"; import { useCookies } from "react-cookie"; -import { environment } from "../../../environments/environment"; +import { environment } from "../../../environments"; import AuthLayout from "./AuthLayout"; import stemmyVine from "../../../assets/images/ActivitiesAssets/stemmy.svg"; diff --git a/react-ystemandchess/src/features/auth/signup/ParentSection.tsx b/react-ystemandchess/src/features/auth/signup/ParentSection.tsx index 84358347..68a2d342 100644 --- a/react-ystemandchess/src/features/auth/signup/ParentSection.tsx +++ b/react-ystemandchess/src/features/auth/signup/ParentSection.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { useCookies } from "react-cookie"; -import { environment } from "../../../environments/environment"; +import { environment } from "../../../environments"; import treesGroup from "../../../assets/images/Trees-Group.png"; import stemmyVine from "../../../assets/images/ActivitiesAssets/stemmy.svg"; import OnboardingSteps from "./OnboardingSteps"; diff --git a/react-ystemandchess/src/features/auth/signup/ParentSignUp.tsx b/react-ystemandchess/src/features/auth/signup/ParentSignUp.tsx index 3413e9cf..c9284bca 100644 --- a/react-ystemandchess/src/features/auth/signup/ParentSignUp.tsx +++ b/react-ystemandchess/src/features/auth/signup/ParentSignUp.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { useNavigate } from "react-router-dom"; import { useCookies } from "react-cookie"; -import { environment } from "../../../environments/environment"; +import { environment } from "../../../environments"; import AuthLayout from "./AuthLayout"; import stemmyVine from "../../../assets/images/ActivitiesAssets/stemmy.svg"; diff --git a/react-ystemandchess/src/features/engine/PlayComputer.test.tsx b/react-ystemandchess/src/features/engine/PlayComputer.test.tsx index 64ef01da..86d6e5fd 100644 --- a/react-ystemandchess/src/features/engine/PlayComputer.test.tsx +++ b/react-ystemandchess/src/features/engine/PlayComputer.test.tsx @@ -42,7 +42,7 @@ jest.mock('../../components/ChessBoard/ChessBoard', () => { }); // Mock environment -jest.mock('../../environments/environment', () => ({ +jest.mock('../../environments', () => ({ environment: { urls: { stockfishServerURL: 'http://localhost:8080', diff --git a/react-ystemandchess/src/features/engine/PlayComputer.tsx b/react-ystemandchess/src/features/engine/PlayComputer.tsx index 03c63f59..4719ff7b 100644 --- a/react-ystemandchess/src/features/engine/PlayComputer.tsx +++ b/react-ystemandchess/src/features/engine/PlayComputer.tsx @@ -6,7 +6,7 @@ import { io } from 'socket.io-client'; import { useLocation } from 'react-router'; import { Move } from '../../core/types/chess'; import ChessBoard, { ChessBoardRef } from '../../components/ChessBoard/ChessBoard'; -import { environment } from "../../environments/environment"; +import { environment } from "../../environments"; // Module styles (placeholder file should exist at the same folder) import styles from './PlayComputer.module.scss'; import StockfishTutor from './StockfishTutor'; diff --git a/react-ystemandchess/src/features/engine/PlayComputerWithTutor.tsx b/react-ystemandchess/src/features/engine/PlayComputerWithTutor.tsx index 4d946fe5..6247bea6 100644 --- a/react-ystemandchess/src/features/engine/PlayComputerWithTutor.tsx +++ b/react-ystemandchess/src/features/engine/PlayComputerWithTutor.tsx @@ -4,7 +4,7 @@ import { Chess as ChessClass } from 'chess.js'; import { io } from 'socket.io-client'; import { Move } from '../../core/types/chess'; import ChessBoard, { ChessBoardRef } from '../../components/ChessBoard/ChessBoard'; -import { environment } from '../../environments/environment'; +import { environment } from '../../environments'; import StockfishTutor from './StockfishTutor'; import styles from './PlayComputer.module.scss'; diff --git a/react-ystemandchess/src/features/engine/StockfishTutor.tsx b/react-ystemandchess/src/features/engine/StockfishTutor.tsx index 24b186fd..b6b759cd 100644 --- a/react-ystemandchess/src/features/engine/StockfishTutor.tsx +++ b/react-ystemandchess/src/features/engine/StockfishTutor.tsx @@ -1,5 +1,6 @@ -import React, { useEffect, useState } from 'react'; -import { environment } from '../../environments/environment'; +import React, { useEffect, useState } from 'react'; +import { useCookies } from 'react-cookie'; +import { environment } from '../../environments'; import styles from './StockfishTutor.module.scss'; import { CoachMascot, CoachExpression } from '../../components/animations/CoachMascot/CoachMascot'; import { Chess as ChessClass } from 'chess.js'; @@ -916,12 +917,16 @@ const getAiFeedbackForMove = async ( moveUci: string, bestMove?: string | null, score?: number | null, - hangingPieceThreat?: string | null + hangingPieceThreat?: string | null, + token?: string ): Promise => { try { const response = await fetch(`${environment.urls.middlewareURL}/chat/chess-feedback`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}) + }, body: JSON.stringify({ fenBefore, fenAfter, @@ -957,6 +962,7 @@ const getAiFeedbackForMove = async ( }; const StockfishTutor: React.FC = ({ enabled, trigger, fenBefore, fenAfter, moveUci, uciHistory, onRequestGotoFen }) => { + const [cookies] = useCookies(['login']); const [isAnalyzing, setIsAnalyzing] = useState(false); const [analysis, setAnalysis] = useState(null); // store the previous turn's matchPoints so we can compute decrement relative to that @@ -1071,11 +1077,10 @@ const StockfishTutor: React.FC = ({ enabled, trigger, fenBefore, fenAfter // eslint-disable-next-line no-console console.debug('StockfishTutor: analyzing move', { fenBefore, fenAfter, moveUci, uciHistory }); try { setDebugLog(`analyzing move ${moveUci} | fenBefore=${fenBefore.split(' ')[0]}...`); } catch (e) { /* ignore */ } - // Safely read environment URL keys + // Stockfish analysis endpoint (/api/analyze) is hosted on stockfishServerURL. + // chessServerURL is websocket-only and does not handle HTTP analysis. const urls = (environment && (environment as any).urls) || {}; - // Support multiple environment keys: prefer unified chessServerURL but - // also accept stockfishServerURL (some deployments use that name). - const rawBase = urls.chessServerURL || urls.stockfishServerURL || urls.chessServer || urls.stockfishServer || ''; + const rawBase = urls.stockfishServerURL || urls.stockfishServer || ''; const baseUrl = typeof rawBase === 'string' ? rawBase.replace(/\/$/, '') : ''; if (!baseUrl) { @@ -1126,7 +1131,7 @@ const StockfishTutor: React.FC = ({ enabled, trigger, fenBefore, fenAfter finalExplanation = ensureEarlySidePawnEnforcement(finalExplanation, moveUci, fenBefore, fenAfter, prevMatchPoints) || finalExplanation; setAnalysis(finalExplanation); const hangingThreat = detectHangingPiece(fenBefore, fenAfter); - const finalExplanationWithAi = finalExplanation ? await getAiFeedbackForMove(finalExplanation, fenBefore, fenAfter, moveUci || '', sfResult.bestMove, typeof sfResult.score === 'number' ? sfResult.score : null, hangingThreat) : null; + const finalExplanationWithAi = finalExplanation ? await getAiFeedbackForMove(finalExplanation, fenBefore, fenAfter, moveUci || '', sfResult.bestMove, typeof sfResult.score === 'number' ? sfResult.score : null, hangingThreat, cookies.login) : null; if (cancelled) return; if (finalExplanationWithAi) enforceHangingPowerPieceBlunder(finalExplanationWithAi, fenBefore, fenAfter); @@ -1179,7 +1184,7 @@ const StockfishTutor: React.FC = ({ enabled, trigger, fenBefore, fenAfter enforceEarlySidePawnBlunder(norm, moveUci, fenBefore); const finalNorm = ensureEarlySidePawnEnforcement(norm, moveUci, fenBefore, fenAfter, prevMatchPoints) || null; const hangingThreat = detectHangingPiece(fenBefore, fenAfter); - const finalNormWithAi = finalNorm ? await getAiFeedbackForMove(finalNorm, fenBefore, fenAfter, moveUci || '', null, norm.score ?? null, hangingThreat) : null; + const finalNormWithAi = finalNorm ? await getAiFeedbackForMove(finalNorm, fenBefore, fenAfter, moveUci || '', null, norm.score ?? null, hangingThreat, cookies.login) : null; if (cancelled) return; if (finalNormWithAi) enforceHangingPowerPieceBlunder(finalNormWithAi, fenBefore, fenAfter); setAnalysis(finalNormWithAi); @@ -1275,7 +1280,7 @@ const StockfishTutor: React.FC = ({ enabled, trigger, fenBefore, fenAfter enforceEarlySidePawnBlunder(norm, moveUci, fenBefore); const finalNorm = ensureEarlySidePawnEnforcement(norm, moveUci, fenBefore, fenAfter, prevMatchPoints) || null; const hangingThreat = detectHangingPiece(fenBefore, fenAfter); - const finalNormWithAi = finalNorm ? await getAiFeedbackForMove(finalNorm, fenBefore, fenAfter, moveUci || '', null, norm.score ?? null, hangingThreat) : null; + const finalNormWithAi = finalNorm ? await getAiFeedbackForMove(finalNorm, fenBefore, fenAfter, moveUci || '', null, norm.score ?? null, hangingThreat, cookies.login) : null; if (cancelled) return; if (finalNormWithAi) enforceHangingPowerPieceBlunder(finalNormWithAi, fenBefore, fenAfter); setAnalysis(finalNormWithAi); @@ -1428,7 +1433,7 @@ const StockfishTutor: React.FC = ({ enabled, trigger, fenBefore, fenAfter // Fetch AI feedback for the server analysis path using the final rating const hangingThreat = detectHangingPiece(fenBefore, fenAfter); if (parsed) { - parsed = await getAiFeedbackForMove(parsed, fenBefore, fenAfter, moveUci || '', engineBest, typeof engineScore === 'number' ? engineScore : null, hangingThreat); + parsed = await getAiFeedbackForMove(parsed, fenBefore, fenAfter, moveUci || '', engineBest, typeof engineScore === 'number' ? engineScore : null, hangingThreat, cookies.login); if (cancelled) return; enforceHangingPowerPieceBlunder(parsed, fenBefore, fenAfter); } diff --git a/react-ystemandchess/src/features/lessons/lessons-selection/LessonsSelection.jsx b/react-ystemandchess/src/features/lessons/lessons-selection/LessonsSelection.jsx index 648c1c23..727ba591 100644 --- a/react-ystemandchess/src/features/lessons/lessons-selection/LessonsSelection.jsx +++ b/react-ystemandchess/src/features/lessons/lessons-selection/LessonsSelection.jsx @@ -1,6 +1,6 @@ import { useNavigate } from "react-router"; import { useState, useEffect, useRef, useCallback, useMemo, memo } from 'react'; -import { environment } from "../../../environments/environment"; +import { environment } from "../../../environments"; import { getAllScenarios } from "../lessons-main/Scenarios"; import { useCookies } from "react-cookie"; diff --git a/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/Lesson-overlay.test.tsx b/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/Lesson-overlay.test.tsx index 55b2fb2f..de3e4ab7 100644 --- a/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/Lesson-overlay.test.tsx +++ b/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/Lesson-overlay.test.tsx @@ -4,7 +4,7 @@ jest.mock("socket.io-client"); // Mock environment -jest.mock("../../../../environments/environment"); +jest.mock("../../../../environments"); // Mock utility modules jest.mock("../../../../core/utils/goalEvaluator"); @@ -103,7 +103,7 @@ const mockIo = io as jest.MockedFunction; // Import mocked modules const goalEvaluator = require("../../../../core/utils/goalEvaluator"); const eventLogger = require("../../../../core/utils/eventLogger"); -const { environment } = require("../../../../environments/environment"); +const { environment } = require("../../../../environments"); // Create socket factory const createMockSocket = () => { diff --git a/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/Lesson-overlay.tsx b/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/Lesson-overlay.tsx index ca31ced4..122f1f65 100644 --- a/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/Lesson-overlay.tsx +++ b/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/Lesson-overlay.tsx @@ -6,7 +6,7 @@ import { io } from 'socket.io-client'; import ChessBoard, { ChessBoardRef } from '../../../../components/ChessBoard/ChessBoard'; import PromotionPopup from '../../lessons-main/PromotionPopup'; import MoveTracker from '../move-tracker/MoveTracker'; -import { environment } from "../../../../environments/environment"; +import { environment } from "../../../../environments"; import { Move } from "../../../../core/types/chess"; import { EvaluationContext, Goal } from '../../../../core/types/goals'; diff --git a/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/hooks/useLessonManager.ts b/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/hooks/useLessonManager.ts index 71dd2194..3a45d1c7 100644 --- a/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/hooks/useLessonManager.ts +++ b/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/hooks/useLessonManager.ts @@ -1,5 +1,5 @@ import { useState, useCallback } from "react"; -import { environment } from "../../../../../environments/environment"; +import { environment } from "../../../../../environments"; export function useLessonManager(piece: string, cookies: any, initialLessonNum?: number) { const [lessonNum, setLessonNum] = useState(initialLessonNum ?? 0); diff --git a/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/hooks/useSocketChessEngine.ts b/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/hooks/useSocketChessEngine.ts index 2b17234e..1bc8ea76 100644 --- a/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/hooks/useSocketChessEngine.ts +++ b/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/hooks/useSocketChessEngine.ts @@ -1,4 +1,4 @@ -import { environment } from "../../../../../environments/environment"; +import { environment } from "../../../../../environments"; import { useEffect, useRef } from "react"; import io from "socket.io-client"; diff --git a/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/hooks/useTimeTracking.test.ts b/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/hooks/useTimeTracking.test.ts index 37cd0a90..cfba444d 100644 --- a/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/hooks/useTimeTracking.test.ts +++ b/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/hooks/useTimeTracking.test.ts @@ -8,7 +8,7 @@ jest.mock("../../../../../globals", () => ({ })); // mock environment URL -jest.mock("../../../../../environments/environment", () => ({ +jest.mock("../../../../../environments", () => ({ environment: { urls: { middlewareURL: "http://mockurl.com" } }, })); diff --git a/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/hooks/useTimeTracking.ts b/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/hooks/useTimeTracking.ts index 15f728bb..c1f70db3 100644 --- a/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/hooks/useTimeTracking.ts +++ b/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/hooks/useTimeTracking.ts @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from "react"; -import { environment } from "../../../../../environments/environment"; +import { environment } from "../../../../../environments"; import { SetPermissionLevel } from "../../../../../globals"; export function useTimeTracking(piece: string, cookies: any) { diff --git a/react-ystemandchess/src/features/mentor/mentor-profile/NewMentorProfile.tsx b/react-ystemandchess/src/features/mentor/mentor-profile/NewMentorProfile.tsx index 4e6429f1..f0210a5c 100644 --- a/react-ystemandchess/src/features/mentor/mentor-profile/NewMentorProfile.tsx +++ b/react-ystemandchess/src/features/mentor/mentor-profile/NewMentorProfile.tsx @@ -3,7 +3,7 @@ import "./NewMentorProfile.scss"; import Images from "../../../assets/images/imageImporter"; import { SetPermissionLevel } from '../../../globals'; import { useCookies } from 'react-cookie'; -import { environment } from "../../../environments/environment"; +import { environment } from "../../../environments"; import { useNavigate } from "react-router"; import StatsChart from "../../student/student-profile/StatsChart"; import Lessons from "../../lessons/lessons-main/Lessons"; diff --git a/react-ystemandchess/src/features/mentor/mentor-profile/NewMentorProfile/NewMentorProfile.tsx b/react-ystemandchess/src/features/mentor/mentor-profile/NewMentorProfile/NewMentorProfile.tsx index b00c3cef..69c96761 100644 --- a/react-ystemandchess/src/features/mentor/mentor-profile/NewMentorProfile/NewMentorProfile.tsx +++ b/react-ystemandchess/src/features/mentor/mentor-profile/NewMentorProfile/NewMentorProfile.tsx @@ -3,7 +3,7 @@ import "./NewMentorProfile.scss"; import Images from "../../../../assets/images/imageImporter"; import { SetPermissionLevel } from '../../../../globals'; import { useCookies } from 'react-cookie'; -import { environment } from "../../../../environments/environment"; +import { environment } from "../../../../environments"; import { useNavigate } from "react-router"; import StatsChart from "../../../student/student-profile/StatsChart"; import Lessons from "../../../lessons/lessons-main/Lessons"; diff --git a/react-ystemandchess/src/features/puzzles/Puzzles.tsx b/react-ystemandchess/src/features/puzzles/Puzzles.tsx index bce687f7..3a22153a 100644 --- a/react-ystemandchess/src/features/puzzles/Puzzles.tsx +++ b/react-ystemandchess/src/features/puzzles/Puzzles.tsx @@ -4,7 +4,7 @@ import { themesDescription, } from "../../core/services/themesService"; import Modal, { ModalProps } from "../../components/modal/Modal"; -import { environment } from "../../environments/environment"; +import { environment } from "../../environments"; import { v4 as uuidv4 } from "uuid"; import { SetPermissionLevel } from "../../globals"; import { useCookies } from "react-cookie"; diff --git a/react-ystemandchess/src/features/student/student-page/Student.tsx b/react-ystemandchess/src/features/student/student-page/Student.tsx index 4d6889ed..a8e28196 100644 --- a/react-ystemandchess/src/features/student/student-page/Student.tsx +++ b/react-ystemandchess/src/features/student/student-page/Student.tsx @@ -2,7 +2,7 @@ import React, { useState, useRef } from "react"; import "./Student.scss"; import ChessBoard, { ChessBoardRef } from "../../../components/ChessBoard/ChessBoard"; import { useChessSocket } from "../../lessons/piece-lessons/lesson-overlay/hooks/useChessSocket"; -import { environment } from "../../../environments/environment"; +import { environment } from "../../../environments"; import { Move } from "../../../core/types/chess"; import { v4 as uuidv4 } from "uuid"; import ChatWidget from '../../../components/ChatWidget/ChatWidget'; diff --git a/react-ystemandchess/src/features/student/student-profile/Modals/ActivitiesModal.tsx b/react-ystemandchess/src/features/student/student-profile/Modals/ActivitiesModal.tsx index 0cf8910f..999175fc 100644 --- a/react-ystemandchess/src/features/student/student-profile/Modals/ActivitiesModal.tsx +++ b/react-ystemandchess/src/features/student/student-profile/Modals/ActivitiesModal.tsx @@ -26,7 +26,7 @@ import { ReactComponent as TopicBag } from "../../../../assets/images/Activities import { ReactComponent as ShortBottomVine} from "../../../../assets/images/ActivitiesAssets/short_bottom_vine.svg"; import { ReactComponent as BottomVine} from "../../../../assets/images/ActivitiesAssets/bottom_vine.svg"; import { ReactComponent as Stemmy} from "../../../../assets/images/ActivitiesAssets/stemmy.svg"; -import { environment } from "../../../../environments/environment"; +import { environment } from "../../../../environments"; import { useCookies } from "react-cookie"; import { parseActivities } from "../../../../core/utils/activityNames"; diff --git a/react-ystemandchess/src/features/student/student-profile/Modals/LeaderboardModal.tsx b/react-ystemandchess/src/features/student/student-profile/Modals/LeaderboardModal.tsx index a95f3b52..31d402d5 100644 --- a/react-ystemandchess/src/features/student/student-profile/Modals/LeaderboardModal.tsx +++ b/react-ystemandchess/src/features/student/student-profile/Modals/LeaderboardModal.tsx @@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom"; import { useCookies } from "react-cookie"; import "./LeaderboardModal.scss"; import { ReactComponent as LeaderboardIcon } from "../../../../assets/images/student/leaderboard_sidebar_icon.svg"; -import { environment } from "../../../../environments/environment"; +import { environment } from "../../../../environments"; import rank1Img from "../../../../assets/images/student/Leaderboard_rank_1.svg"; import rank2Img from "../../../../assets/images/student/Leaderboard_rank_2.svg"; diff --git a/react-ystemandchess/src/features/student/student-profile/NewStudentProfile.tsx b/react-ystemandchess/src/features/student/student-profile/NewStudentProfile.tsx index 9ebfc156..de971eba 100644 --- a/react-ystemandchess/src/features/student/student-profile/NewStudentProfile.tsx +++ b/react-ystemandchess/src/features/student/student-profile/NewStudentProfile.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useRef, useMemo, lazy, Suspense } from "react"; import { SetPermissionLevel } from '../../../globals'; import { useCookies } from 'react-cookie'; -import { environment } from "../../../environments/environment"; +import { environment } from "../../../environments"; import { useNavigate } from "react-router"; import StatsChart from "./StatsChart"; import Puzzles from "../../puzzles/Puzzles"; diff --git a/react-ystemandchess/src/globals.ts b/react-ystemandchess/src/globals.ts index ae5fd9ad..d17f9df9 100644 --- a/react-ystemandchess/src/globals.ts +++ b/react-ystemandchess/src/globals.ts @@ -5,7 +5,7 @@ * components, particularly for authentication and permission management. */ -import { environment } from "./environments/environment"; +import { environment } from "./environments"; /** * Global variable to store user information after authentication diff --git a/scripts/check-pr-authorship.sh b/scripts/check-pr-authorship.sh new file mode 100644 index 00000000..4ffdee64 --- /dev/null +++ b/scripts/check-pr-authorship.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE_REF="${1:-main}" + +echo "Checking PR commit authorship against origin/${BASE_REF}..." +git fetch origin "${BASE_REF}" --depth=50 2>/dev/null || true + +AUTHORS=$(git log --format='%an' "origin/${BASE_REF}..HEAD" | sort -u | grep -v '^$' || true) +NORMALIZED_AUTHORS=$(echo "$AUTHORS" | sed 's/ToldYO/Ahmad Nakhala/' | sort -u | grep -v '^$' || true) +AUTHOR_COUNT=$(echo "$NORMALIZED_AUTHORS" | grep -v '^$' | wc -l) + +echo "Commit authors on branch:" +echo "$AUTHORS" + +if [ "$AUTHOR_COUNT" -gt 1 ]; then + echo "ERROR: Branch contains commits by $AUTHOR_COUNT different authors." + echo "Base your branch on the upstream PR branch or wait for it to land on main to keep PR authorship clean." + exit 1 +fi + +echo "Authorship check passed (author: $NORMALIZED_AUTHORS)." diff --git a/stockfishServer/.env.example b/stockfishServer/.env.example new file mode 100644 index 00000000..7d7bca16 --- /dev/null +++ b/stockfishServer/.env.example @@ -0,0 +1,4 @@ +NODE_ENV=development +PORT=8080 +CORS_ORIGIN=http://localhost:3000 +ALLOWED_ORIGINS= diff --git a/stockfishServer/src/index.js b/stockfishServer/src/index.js index 387ee56a..66cd0ba3 100644 --- a/stockfishServer/src/index.js +++ b/stockfishServer/src/index.js @@ -1,20 +1,29 @@ require("dotenv").config(); +const validateEnvironment = require("./validateEnvironment"); +validateEnvironment(); + const express = require("express"); const http = require("http"); const { Server } = require("socket.io"); const cors = require("cors"); const initializeSocket = require("./managers/socket"); +const isProduction = process.env.NODE_ENV === "production"; + const allowedOriginsSetting = process.env.CORS_ORIGIN || process.env.ALLOWED_ORIGINS; const allowedOrigins = allowedOriginsSetting ? allowedOriginsSetting.split(",").map((o) => o.trim()) : [ "https://ystemandchess.com", "https://www.ystemandchess.com", - "http://localhost:3000", - "http://localhost:3002", - "http://localhost:4200", + ...(isProduction + ? [] + : [ + "http://localhost:3000", + "http://localhost:3002", + "http://localhost:4200", + ]), ]; const hasWildcard = allowedOrigins.includes("*"); @@ -28,7 +37,7 @@ const corsOptions = { if (allowedOrigins.indexOf(origin) !== -1) { callback(null, true); } else { - callback(new Error(`Origin ${origin} not allowed by CORS`)); + callback(null, false); } }, methods: ["GET", "POST"], diff --git a/stockfishServer/src/validateEnvironment.js b/stockfishServer/src/validateEnvironment.js new file mode 100644 index 00000000..4c94408f --- /dev/null +++ b/stockfishServer/src/validateEnvironment.js @@ -0,0 +1,29 @@ +function hasCorsConfiguration() { + return Boolean( + process.env.CORS_ORIGIN?.trim() || + process.env.ALLOWED_ORIGINS?.trim() + ); +} + +function validateEnvironment() { + if (process.env.NODE_ENV !== "production") { + return; + } + + const missing = []; + + if (!hasCorsConfiguration()) { + missing.push("CORS_ORIGIN or ALLOWED_ORIGINS"); + } + + if (missing.length > 0) { + console.error( + `[stockfishServer] Missing required production environment variables: ${missing.join(", ")}` + ); + process.exit(1); + } + + console.log("[stockfishServer] Required production environment variables validated"); +} + +module.exports = validateEnvironment;