From 7e3b0c5f7af15c980fbd7649061996f86582b63c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:40:07 +0000 Subject: [PATCH] Cleanup: drop Vercel path, wire rate-limit env vars, add tests, harden CI, refresh docs - Remove the unused Vercel deployment (api/, vercel.json, .vercelignore, root tsconfig.json) plus dead start-dev.js, server/package.json.backup and server/tsconfig.node.json; Cloudflare Pages is the only target - Read WORKSHEET_/GENERAL_RATE_LIMIT_* from the environment (validated positive integers) instead of documenting knobs that were never read; drop the never-implemented MAX_REQUEST_SIZE from docs - Fix dev-port drift: .env.example/README/vite proxy now say 3001 and the vite proxy no longer strips the /api prefix - Add tests: request schema + rate-limit config (node:test via tsx) and the Cloudflare function (stubbed fetch, in-memory KV); npm test runs both and CI runs it - CI: permissions: contents: read, SHA-pinned actions, persist-credentials off, typecheck includes tests; Dependabot covers github-actions - Add favicon.svg (index.html pointed at a missing /vite.svg) - Rewrite README/server README to match the actual stack and Cloudflare deployment Co-Authored-By: Jason Maycock --- .agents/skills/testing-learninglab/SKILL.md | 2 +- .env.example | 16 +- .github/dependabot.yml | 6 +- .github/workflows/ci.yml | 24 +- .gitignore | 11 +- .vercelignore | 57 ---- README.md | 320 +++++++----------- api/generate-worksheet.js | 139 -------- api/health.js | 15 - api/health.ts | 10 - api/index.ts | 77 ----- package.json | 1 + server/.gitignore | 3 +- server/README.md | 213 +++--------- server/client/index.html | 2 +- server/client/package.json | 1 - server/client/public/favicon.svg | 1 + .../client/src/hooks/useServerConnection.ts | 4 +- server/client/vite.config.ts | 8 +- server/eslint.config.js | 4 +- server/package.json | 2 +- server/package.json.backup | 27 -- server/src/middleware/rateLimiting.test.ts | 56 +++ server/src/middleware/rateLimiting.ts | 43 ++- server/src/middleware/validation.test.ts | 77 +++++ server/tsconfig.eslint.json | 6 + server/tsconfig.node.json | 17 - start-dev.js | 56 --- test/cloudflare-function.test.mjs | 197 +++++++++++ tsconfig.json | 28 -- vercel.json | 10 - 31 files changed, 570 insertions(+), 863 deletions(-) delete mode 100644 .vercelignore delete mode 100644 api/generate-worksheet.js delete mode 100644 api/health.js delete mode 100644 api/health.ts delete mode 100644 api/index.ts create mode 100644 server/client/public/favicon.svg delete mode 100644 server/package.json.backup create mode 100644 server/src/middleware/rateLimiting.test.ts create mode 100644 server/src/middleware/validation.test.ts create mode 100644 server/tsconfig.eslint.json delete mode 100644 server/tsconfig.node.json delete mode 100644 start-dev.js create mode 100644 test/cloudflare-function.test.mjs delete mode 100644 tsconfig.json delete mode 100644 vercel.json diff --git a/.agents/skills/testing-learninglab/SKILL.md b/.agents/skills/testing-learninglab/SKILL.md index 486273e..7e2b066 100644 --- a/.agents/skills/testing-learninglab/SKILL.md +++ b/.agents/skills/testing-learninglab/SKILL.md @@ -13,7 +13,7 @@ description: How to run and end-to-end test the LearningLab worksheet generator - Root and server deps require Node **>=22.12** — run `source ~/.nvm/nvm.sh && nvm use 22` in every shell before npm/npx (the default node may be older). - `server` is a root npm **workspace** and has no own lockfile: install with `npm install` at the repo root (this also installs server deps), then `npm install` in `server/client`. Root `npm install` may rewrite the `engines.node` metadata in the tracked root `package-lock.json` — check `git diff package-lock.json` and `git checkout -- package-lock.json` if it is only that. - Static checks that should pass: root `npm run build` (client), `cd server && npx tsc --noEmit && npx eslint .` (eslint 10 flat config; warnings are pre-existing, only errors matter), `cd server/client && npx tsc --noEmit`. -- Duplicated serverless copies of the endpoints: `api/generate-worksheet.js` (Vercel-style, CommonJS) and `functions/api/generate-worksheet.js` (Cloudflare Pages, ESM `onRequestPost`). Real production is Cloudflare Pages: static client + `functions/api/*`. +- Serverless copy of the endpoint: `functions/api/generate-worksheet.js` (Cloudflare Pages, ESM `onRequestPost`). Real production is Cloudflare Pages: static client + `functions/api/*`. Tests: `npm test` at the root (Cloudflare function via `test/*.test.mjs` with stubbed `fetch`, server via `server/src/**/*.test.ts`). ## Ports / wiring gotcha In dev the client does **not** use the vite `/api` proxy: `server/client/src/config/constants.ts` hardcodes `http://localhost:3001` for non-production mode (in production mode the bundle uses a same-origin empty baseUrl). So the Express server must listen on **3001** (`PORT=3001`) and `CORS_ORIGIN` must be `http://localhost:5173`, otherwise the UI shows "Unable to connect to server". diff --git a/.env.example b/.env.example index 18ee550..0d2dbb5 100644 --- a/.env.example +++ b/.env.example @@ -1,21 +1,21 @@ +# Copy this file to server/.env (the Express server loads server/.env, not the repo root). + # OpenAI API Configuration OPENAI_API_KEY=your_openai_api_key_here # Server Configuration -PORT=3000 +PORT=3001 NODE_ENV=development # Security CORS_ORIGIN=http://localhost:5173 # API Rate Limiting (Optional - defaults are secure) +# Positive integers; the defaults shown apply when unset. # General API rate limiting (15 minutes window) -GENERAL_RATE_LIMIT_WINDOW_MS=900000 -GENERAL_RATE_LIMIT_MAX_REQUESTS=100 +# GENERAL_RATE_LIMIT_WINDOW_MS=900000 +# GENERAL_RATE_LIMIT_MAX_REQUESTS=100 # Worksheet generation rate limiting (1 hour window) -WORKSHEET_RATE_LIMIT_WINDOW_MS=3600000 -WORKSHEET_RATE_LIMIT_MAX_REQUESTS=10 - -# Request size limits (bytes) -MAX_REQUEST_SIZE=1024 +# WORKSHEET_RATE_LIMIT_WINDOW_MS=3600000 +# WORKSHEET_RATE_LIMIT_MAX_REQUESTS=10 diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 8d12003..1ff94cb 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -13,7 +13,7 @@ updates: # typescript-eslint does not support TypeScript 7 yet. - dependency-name: "typescript" update-types: ["version-update:semver-major"] - # Track the Node 22 runtime (engines / .node-version / vercel.json). + # Track the Node 22 runtime (engines / .node-version). - dependency-name: "@types/node" update-types: ["version-update:semver-major"] # Client has its own lockfile (not part of the workspace). @@ -28,3 +28,7 @@ updates: ignore: - dependency-name: "typescript" update-types: ["version-update:semver-major"] + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2b3be9..69031cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,9 @@ on: branches: [main] pull_request: +permissions: + contents: read + concurrency: group: ci-${{ github.ref }} cancel-in-progress: true @@ -14,9 +17,11 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version-file: .node-version cache: npm @@ -31,8 +36,8 @@ jobs: run: npm ci working-directory: server/client - - name: Typecheck server - run: npx tsc --noEmit + - name: Typecheck server (incl. tests) + run: npx tsc --noEmit -p tsconfig.eslint.json working-directory: server - name: Lint server @@ -43,6 +48,9 @@ jobs: run: npx tsc --noEmit working-directory: server/client + - name: Test (Cloudflare function + server) + run: npm test + - name: Build client run: npm run build @@ -50,19 +58,13 @@ jobs: run: npx tsc working-directory: server - - name: Serverless handlers load - run: | - node -e "const h = require('./api/generate-worksheet.js'); if (typeof h !== 'function') process.exit(1)" - node --input-type=module -e "import('./functions/api/generate-worksheet.js').then(m => { if (typeof m.onRequestPost !== 'function') process.exit(1) })" - env: - OPENAI_API_KEY: ci-dummy - - name: Production smoke test run: | NODE_ENV=production PORT=3001 OPENAI_API_KEY=ci-dummy node server/dist/index.js & for i in $(seq 1 20); do curl -sf localhost:3001/api/health && break; sleep 1; done curl -sf localhost:3001/api/health curl -sf localhost:3001/ | grep -q '
20) { - return 'gradeLevel must be a string of 1-20 characters'; - } - const normalizedGrade = gradeLevel.toLowerCase().trim().replace(/\s+grade$/, ''); - if (!VALID_GRADE_LEVELS.includes(normalizedGrade)) { - return 'Invalid grade level. Please use K-12, Kindergarten-12th, Elementary, Middle School, or High School.'; - } - if (typeof topic !== 'string' || topic.trim().length < 3 || topic.length > 100) { - return 'topic must be a string of 3-100 characters'; - } - const lowerTopic = topic.toLowerCase(); - if (INAPPROPRIATE_WORDS.some(word => lowerTopic.includes(word))) { - return 'Topic contains inappropriate content. Please choose an educational topic suitable for students.'; - } - if (/(.)\1{4,}/.test(topic)) { - return 'Topic appears to contain spam-like content.'; - } - if (complexity !== undefined && !['easy', 'medium', 'hard'].includes(complexity)) { - return "complexity must be one of 'easy', 'medium', or 'hard'"; - } - return null; -} - -async function generateWorksheetContent(gradeLevel, topic, complexity = 'medium') { - const prompt = "Create an age-appropriate reading comprehension passage and questions for " + gradeLevel + " grade students about " + topic + ". \n Difficulty level: " + complexity + ". \n Include:\n 1. A title\n 2. A passage (250-400 words)\n 3. 5 multiple-choice questions\n 4. 2 short-answer questions\n 5. Answer key\n Format the response in JSON with the following structure:\n {\n \"title\": \"string\",\n \"passage\": \"string\",\n \"multipleChoice\": [\n {\n \"question\": \"string\",\n \"options\": [\"string\", \"string\", \"string\", \"string\"],\n \"answer\": \"string\"\n }\n ],\n \"shortAnswer\": [\n {\n \"question\": \"string\",\n \"answer\": \"string\"\n }\n ]\n }"; - - const completion = await openai.chat.completions.create({ - model: OPENAI_CONFIG.model, - messages: [ - { - role: "system", - content: OPENAI_CONFIG.systemMessage - }, - { - role: "user", - content: prompt - } - ], - response_format: { type: "json_object" } - }); - - const content = completion.choices[0].message.content; - if (!content) { - throw new Error('OpenAI returned empty content'); - } - - return JSON.parse(content); -} - -// Same-origin requests are always allowed. Additional origins can be -// allow-listed via ALLOWED_ORIGINS (comma-separated). -function resolveAllowedOrigin(req) { - const origin = req.headers.origin; - if (!origin) return null; - const proto = req.headers['x-forwarded-proto'] || 'https'; - const host = req.headers['x-forwarded-host'] || req.headers.host; - const selfOrigin = `${proto}://${host}`; - const allowList = (process.env.ALLOWED_ORIGINS || '') - .split(',') - .map(o => o.trim()) - .filter(Boolean); - return origin === selfOrigin || allowList.includes(origin) ? origin : null; -} - -module.exports = async (req, res) => { - const allowedOrigin = resolveAllowedOrigin(req); - res.setHeader('Vary', 'Origin'); - if (allowedOrigin) { - res.setHeader('Access-Control-Allow-Origin', allowedOrigin); - res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); - res.setHeader('Access-Control-Max-Age', '86400'); - } - - if (req.headers.origin && !allowedOrigin) { - return res.status(403).json({ error: 'Origin not allowed' }); - } - - if (req.method === 'OPTIONS') { - res.status(204).end(); - return; - } - - // Only allow POST requests - if (req.method !== 'POST') { - return res.status(405).json({ error: 'Method not allowed' }); - } - - try { - const { gradeLevel, topic, complexity = 'medium' } = req.body; - console.log('Generating worksheet for:', { gradeLevel, topic, complexity }); - - const validationError = validateWorksheetRequest({ gradeLevel, topic, complexity }); - if (validationError) { - return res.status(400).json({ - error: 'Invalid request data', - details: validationError - }); - } - - const worksheet = await generateWorksheetContent(gradeLevel, topic, complexity); - res.json(worksheet); - } catch (error) { - // Upstream errors can embed provider details and partially masked API keys, - // so they are logged server-side and never forwarded to the client. - console.error('Error generating worksheet:', error); - res.status(500).json({ - error: 'Failed to generate worksheet', - details: 'Please try again. If the problem persists, contact support.' - }); - } -}; diff --git a/api/health.js b/api/health.js deleted file mode 100644 index 174f1c8..0000000 --- a/api/health.js +++ /dev/null @@ -1,15 +0,0 @@ -module.exports = (req, res) => { - // Enable CORS - res.setHeader('Access-Control-Allow-Credentials', true); - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Access-Control-Allow-Methods', 'GET,OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version'); - - // Handle OPTIONS request - if (req.method === 'OPTIONS') { - res.status(200).end(); - return; - } - - res.status(200).json({ status: 'ok', message: 'API is running' }); -}; diff --git a/api/health.ts b/api/health.ts deleted file mode 100644 index 5598bf3..0000000 --- a/api/health.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { VercelRequest, VercelResponse } from '@vercel/node'; - -export default function handler(req: VercelRequest, res: VercelResponse) { - res.status(200).json({ - status: 'ok', - timestamp: new Date().toISOString(), - environment: process.env.NODE_ENV || 'production', - version: '1.0.0' - }); -} diff --git a/api/index.ts b/api/index.ts deleted file mode 100644 index ef03375..0000000 --- a/api/index.ts +++ /dev/null @@ -1,77 +0,0 @@ -// This file is a Vercel serverless function entry point -import { VercelRequest, VercelResponse } from '@vercel/node'; -import express, { Request, Response, NextFunction } from 'express'; -import cors from 'cors'; - -// Define custom error interface -interface ErrorWithStatus extends Error { - status?: number; - message: string; -} - -// Initialize Express app -const app = express(); - -// Middleware -app.use(cors({ - origin: process.env.CORS_ORIGIN || '*', - credentials: true -})); - -app.use(express.json()); - -// Create router for API routes -const router = express.Router(); - -// Main API route - add your API endpoints here -router.post('/worksheet/generate', (req: Request, res: Response) => { - try { - // This is a placeholder for the actual implementation - const { text, grade, subject } = req.body; - - if (!text) { - res.status(400).json({ error: 'Text is required' }); - return; - } - - // In a real implementation, this would call the OpenAI API - // For now, just return a mock response - res.status(200).json({ - success: true, - worksheet: { - questions: [ - { id: 1, question: 'Sample question 1 about ' + text.substring(0, 20) + '...?' }, - { id: 2, question: 'Sample question 2 about ' + text.substring(0, 20) + '...?' }, - ], - title: `Worksheet for ${subject || 'General'} (Grade ${grade || 'K-12'})`, - text: text.substring(0, 100) + '...' - } - }); - } catch (error) { - console.error('Error generating worksheet:', error); - res.status(500).json({ error: 'Failed to generate worksheet' }); - } -}); - -// Mount the router at /api -app.use('/api', router); - -// 404 handler for API routes -app.use('/api/*splat', (req: Request, res: Response) => { - res.status(404).json({ - error: 'API endpoint not found', - path: req.path, - method: req.method - }); -}); - -// Error handling middleware -app.use((err: ErrorWithStatus, req: Request, res: Response, next: NextFunction) => { - console.error('Error:', err); - const status = err.status || 500; - const message = err.message || 'Internal Server Error'; - res.status(status).json({ error: message }); -}); - -// Export the Express app as a Vercel serverless function -export default app; diff --git a/package.json b/package.json index b9ba13b..59a6261 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "build:client": "cd server/client && npm run build", "build:server": "cd server && npm run build", "start": "cd server && npm run start", + "test": "node --test \"test/**/*.test.mjs\" && npm test --workspace server", "preview": "cd server && npm run preview" }, "workspaces": [ diff --git a/server/.gitignore b/server/.gitignore index c7ab0ea..a2c32d9 100644 --- a/server/.gitignore +++ b/server/.gitignore @@ -87,6 +87,8 @@ dist # Gatsby files .cache/ public +# Vite static assets (copied verbatim into client/dist) +!client/public # vuepress build output .vuepress/dist @@ -124,4 +126,3 @@ public *.njsproj *.sln *.sw? -.vercel diff --git a/server/README.md b/server/README.md index 928c51d..7cbc4eb 100644 --- a/server/README.md +++ b/server/README.md @@ -1,193 +1,64 @@ # LearningLab Server -This is the backend server for LearningLab, an AI-Powered Reading Comprehension Worksheet Generator. +Express 5 + TypeScript API for LearningLab, used for local development and for self-hosting on a Node server. Production runs on Cloudflare Pages Functions (`../functions/api`), which mirror this API. -## Prerequisites +See the [root README](../README.md) for setup, environment variables and deployment. -- Node.js >= 16.0.0 -- npm >= 7.0.0 -- TypeScript >= 4.7.0 +## Layout -## Getting Started - -### Installation - -1. Clone the repository: - ```bash - git clone https://github.com/your-username/learninglab.git - cd learninglab/server - ``` - -2. Install dependencies: - ```bash - npm install - ``` - -3. Create a `.env` file in the server root directory and add the required environment variables: - ```env - # Server Configuration - PORT=3000 - NODE_ENV=development - - # CORS Configuration - CORS_ORIGIN=http://localhost:5173 - - # OpenAI Configuration - OPENAI_API_KEY=your_openai_api_key - ``` - -### Development - -To start the development server with hot-reload: - -```bash -npm run dev -``` - -This will start the server with nodemon, which will automatically restart the server when files change. - -### Building for Production - -To build the application for production: - -```bash -npm run build ``` - -This will compile the TypeScript code to JavaScript in the `dist` directory. - -### Running in Production - -To start the application in production mode: - -```bash -npm start -``` - -## Project Structure - -``` -server/ -├── src/ # Source files -│ ├── config/ # Configuration files -│ ├── controllers/ # Route controllers -│ ├── middleware/ # Custom middleware -│ ├── models/ # Database models -│ ├── routes/ # API routes -│ ├── services/ # Business logic -│ ├── types/ # TypeScript type definitions -│ ├── utils/ # Utility functions -│ ├── app.ts # Express application setup -│ └── index.ts # Application entry point -├── client/ # Frontend React application -├── public/ # Static files (served in production) -├── .env # Environment variables -├── .eslintrc.cjs # ESLint configuration -├── .prettierrc # Prettier configuration -├── package.json # Project dependencies and scripts -└── tsconfig.json # TypeScript configuration -``` - -## API Documentation - -### Health Check - -- **GET /api/health** - - Description: Check if the API is running - - Response: - ```json - { - "status": "ok", - "timestamp": "2023-05-19T17:30:00.000Z" - } - ``` - -### Error Handling - -The API uses standard HTTP status codes to indicate the success or failure of an API request. - -- `200 OK` - The request was successful -- `400 Bad Request` - The request was invalid -- `401 Unauthorized` - Authentication is required -- `403 Forbidden` - The user doesn't have permission to access the resource -- `404 Not Found` - The requested resource was not found -- `500 Internal Server Error` - An error occurred on the server - -## Testing - -To run tests: - -```bash -npm test +src/ +├── config/ # server/.env loading, OpenAI settings +├── controllers/ # POST /api/generate-worksheet +├── middleware/ # validation (Zod), rate limiting, error handling +├── routes/ +├── services/ # OpenAI call and prompt +├── types/ +└── utils/ +client/ # React front end (separate lockfile) ``` -## Linting and Formatting - -To check for linting errors: - -```bash -npm run lint -``` +## Commands (run from `server/`) -To automatically fix linting errors: +| Command | Description | +|---------|-------------| +| `npm run dev` | `tsx watch` with hot reload on port 3001 | +| `npm run build:server` | Compile to `dist/` | +| `npm start` | Run `dist/index.js` | +| `npm test` | Node test runner (`src/**/*.test.ts`) | +| `npm run lint` | ESLint with auto-fix | +| `npm run format` | Prettier | +| `npx tsc --noEmit -p tsconfig.eslint.json` | Typecheck including tests | -```bash -npm run lint:fix -``` +## API -To format code according to Prettier: +### `GET /api/health` -```bash -npm run format +```json +{ "status": "ok", "timestamp": "2025-01-01T00:00:00.000Z", "environment": "development" } ``` -## Environment Variables - -| Variable | Description | Default | -|----------|-------------|---------| -| PORT | Port the server will run on | 3000 | -| NODE_ENV | Application environment (development, production) | development | -| CORS_ORIGIN | Allowed CORS origin | http://localhost:5173 | -| OPENAI_API_KEY | OpenAI API key | - | +### `POST /api/generate-worksheet` -## Deployment +Request (JSON, max 1 KB): -### Docker - -Build the Docker image: - -```bash -docker build -t learninglab-server . -``` - -Run the Docker container: - -```bash -docker run -p 3000:3000 --env-file .env learninglab-server +```json +{ "gradeLevel": "3rd Grade", "topic": "Volcanoes", "complexity": "medium" } ``` -### PM2 +- `gradeLevel`: `Kindergarten`, `1st Grade` … `12th Grade` (also `K`, `1`-`12`, `Elementary`, `Middle School`, `High School`) +- `topic`: 3-100 characters; inappropriate and spam-like topics are rejected +- `complexity`: optional, `easy` | `medium` | `hard` -Install PM2 globally: +Response `200`: -```bash -npm install -g pm2 +```json +{ + "title": "...", + "passage": "...", + "multipleChoice": [{ "question": "...", "options": ["...", "..."], "answer": "..." }], + "shortAnswer": [{ "question": "...", "answer": "..." }] +} ``` -Start the application with PM2: - -```bash -NODE_ENV=production pm2 start dist/index.js --name learninglab-server -``` - -## Contributing - -1. Fork the repository -2. Create your feature branch (`git checkout -b feature/AmazingFeature`) -3. Commit your changes (`git commit -m 'Add some AmazingFeature'`) -4. Push to the branch (`git push origin feature/AmazingFeature`) -5. Open a Pull Request - -## License - -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. +Errors: `400` invalid input, `413` body too large, `429` rate limited (`RateLimit-*` headers), `500` upstream failure (details are never forwarded to the client). diff --git a/server/client/index.html b/server/client/index.html index 898abc7..87a619f 100644 --- a/server/client/index.html +++ b/server/client/index.html @@ -2,7 +2,7 @@ - + LearningLab - Reading Comprehension Worksheets diff --git a/server/client/package.json b/server/client/package.json index 7ddc060..3caab16 100644 --- a/server/client/package.json +++ b/server/client/package.json @@ -6,7 +6,6 @@ "scripts": { "dev": "vite", "build": "vite build", - "vercel-build": "vite build", "build:with-types": "tsc && vite build", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "preview": "vite preview" diff --git a/server/client/public/favicon.svg b/server/client/public/favicon.svg new file mode 100644 index 0000000..37ed7c6 --- /dev/null +++ b/server/client/public/favicon.svg @@ -0,0 +1 @@ + diff --git a/server/client/src/hooks/useServerConnection.ts b/server/client/src/hooks/useServerConnection.ts index 8fc90b0..7c9410c 100644 --- a/server/client/src/hooks/useServerConnection.ts +++ b/server/client/src/hooks/useServerConnection.ts @@ -7,7 +7,7 @@ interface ServerConnectionState { } export const useServerConnection = (): ServerConnectionState => { - const [serverPort, setServerPort] = useState(3000); // Always assume server is available in production + const [serverPort, setServerPort] = useState(3001); // Always assume server is available in production const [error, setError] = useState(null); useEffect(() => { @@ -23,7 +23,7 @@ export const useServerConnection = (): ServerConnectionState => { const response = await fetch(`${API_CONFIG.baseUrl}${API_CONFIG.endpoints.health}`); if (response.ok) { console.log('Server health check passed'); - setServerPort(3000); + setServerPort(3001); setError(null); } } catch (err) { diff --git a/server/client/vite.config.ts b/server/client/vite.config.ts index 8d4f6dc..305fe04 100644 --- a/server/client/vite.config.ts +++ b/server/client/vite.config.ts @@ -1,5 +1,4 @@ // @ts-nocheck -// Simplified Vite configuration for Vercel deployment import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import tailwindcss from '@tailwindcss/vite'; @@ -31,12 +30,11 @@ export default defineConfig({ strictPort: true, // Exit if port is already in use open: true, // Open browser on server start proxy: { - // Proxy API requests to the backend server + // Proxy API requests to the Express dev server (src/config/constants.ts + // talks to it directly in dev; the proxy keeps `curl localhost:5173/api/*` working) '/api': { - target: 'http://localhost:3000', + target: 'http://localhost:3001', changeOrigin: true, - secure: false, - rewrite: (path) => path.replace(/^\/api/, ''), }, }, }, diff --git a/server/eslint.config.js b/server/eslint.config.js index 5a287f6..5bf930d 100644 --- a/server/eslint.config.js +++ b/server/eslint.config.js @@ -22,7 +22,7 @@ export default [ parserOptions: { ecmaVersion: 'latest', sourceType: 'module', - project: './tsconfig.json', + project: './tsconfig.eslint.json', }, globals: { ...globals.node, @@ -35,7 +35,7 @@ export default [ settings: { 'import/resolver': { typescript: { - project: './tsconfig.json', + project: './tsconfig.eslint.json', }, }, }, diff --git a/server/package.json b/server/package.json index b795806..815b66e 100644 --- a/server/package.json +++ b/server/package.json @@ -11,7 +11,7 @@ "build": "npm run build:client && npm run build:server", "build:client": "cd client && npm install && npm run build", "build:server": "tsc", - "test": "echo \"Error: no test specified\" && exit 1", + "test": "node --import tsx --test \"src/**/*.test.ts\"", "preview": "NODE_ENV=production npm run build && node dist/index.js", "clean": "rm -rf dist node_modules && cd client && rm -rf node_modules", "lint": "eslint . --fix", diff --git a/server/package.json.backup b/server/package.json.backup deleted file mode 100644 index 00ce60b..0000000 --- a/server/package.json.backup +++ /dev/null @@ -1,27 +0,0 @@ -{ - "name": "learninglab-server", - "version": "1.0.0", - "description": "Server for LearningLab - AI-Powered Reading Comprehension Worksheet Generator", - "main": "dist/index.js", - "scripts": { - "start": "node dist/index.js", - "dev": "nodemon --exec ts-node src/index.ts", - "build": "tsc", - "test": "echo \"Error: no test specified\" && exit 1" - }, - "dependencies": { - "cors": "^2.8.5", - "dotenv": "^16.5.0", - "express": "^5.1.0", - "openai": "^4.100.0", - "zod": "^3.24.2" - }, - "devDependencies": { - "@types/cors": "^2.8.18", - "@types/express": "^5.0.2", - "@types/node": "^22.15.19", - "nodemon": "^3.1.0", - "ts-node": "^10.9.2", - "typescript": "^5.8.3" - } -} diff --git a/server/src/middleware/rateLimiting.test.ts b/server/src/middleware/rateLimiting.test.ts new file mode 100644 index 0000000..dfd4891 --- /dev/null +++ b/server/src/middleware/rateLimiting.test.ts @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const modulePath = fileURLToPath(new URL('./rateLimiting.ts', import.meta.url)); + +// The limits are read once at module load, so each case runs in a fresh process. +const load = (env: Record) => + spawnSync( + process.execPath, + [ + '--import', + 'tsx', + '--input-type=module', + '-e', + `import * as m from ${JSON.stringify(modulePath)}; + console.log(JSON.stringify({ + w: m.WORKSHEET_RATE_LIMIT_WINDOW_MS, wm: m.WORKSHEET_RATE_LIMIT_MAX_REQUESTS, + g: m.GENERAL_RATE_LIMIT_WINDOW_MS, gm: m.GENERAL_RATE_LIMIT_MAX_REQUESTS, + }));`, + ], + { encoding: 'utf8', env: { ...process.env, ...env } } + ); + +describe('rate limit configuration', () => { + it('falls back to the documented defaults', () => { + const result = load({ + WORKSHEET_RATE_LIMIT_WINDOW_MS: '', + WORKSHEET_RATE_LIMIT_MAX_REQUESTS: '', + GENERAL_RATE_LIMIT_WINDOW_MS: '', + GENERAL_RATE_LIMIT_MAX_REQUESTS: '', + }); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(JSON.parse(result.stdout), { w: 3_600_000, wm: 10, g: 900_000, gm: 100 }); + }); + + it('reads overrides from the environment', () => { + const result = load({ + WORKSHEET_RATE_LIMIT_WINDOW_MS: '60000', + WORKSHEET_RATE_LIMIT_MAX_REQUESTS: '2', + GENERAL_RATE_LIMIT_WINDOW_MS: '30000', + GENERAL_RATE_LIMIT_MAX_REQUESTS: '5', + }); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(JSON.parse(result.stdout), { w: 60_000, wm: 2, g: 30_000, gm: 5 }); + }); + + it('refuses to start with a non-positive or non-integer limit', () => { + for (const bad of ['0', '-5', '1.5', 'ten']) { + const result = load({ WORKSHEET_RATE_LIMIT_MAX_REQUESTS: bad }); + assert.notEqual(result.status, 0, bad); + assert.match(result.stderr, /WORKSHEET_RATE_LIMIT_MAX_REQUESTS must be a positive integer/); + } + }); +}); diff --git a/server/src/middleware/rateLimiting.ts b/server/src/middleware/rateLimiting.ts index 53d9e18..0d45aae 100644 --- a/server/src/middleware/rateLimiting.ts +++ b/server/src/middleware/rateLimiting.ts @@ -1,14 +1,41 @@ import { rateLimit } from 'express-rate-limit'; import { slowDown } from 'express-slow-down'; +const envInt = (name: string, fallback: number): number => { + const raw = process.env[name]; + if (raw === undefined || raw === '') return fallback; + const value = Number(raw); + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`${name} must be a positive integer, got "${raw}"`); + } + return value; +}; + +export const WORKSHEET_RATE_LIMIT_WINDOW_MS = envInt( + 'WORKSHEET_RATE_LIMIT_WINDOW_MS', + 60 * 60 * 1000 +); +export const WORKSHEET_RATE_LIMIT_MAX_REQUESTS = envInt('WORKSHEET_RATE_LIMIT_MAX_REQUESTS', 10); +export const GENERAL_RATE_LIMIT_WINDOW_MS = envInt('GENERAL_RATE_LIMIT_WINDOW_MS', 15 * 60 * 1000); +export const GENERAL_RATE_LIMIT_MAX_REQUESTS = envInt('GENERAL_RATE_LIMIT_MAX_REQUESTS', 100); + +const describeWindow = (ms: number): string => { + if (ms % (60 * 60 * 1000) === 0) { + const h = ms / (60 * 60 * 1000); + return `${h} hour${h === 1 ? '' : 's'}`; + } + if (ms % (60 * 1000) === 0) return `${ms / (60 * 1000)} minutes`; + return `${Math.ceil(ms / 1000)} seconds`; +}; + // Strict rate limiting for worksheet generation (most expensive operation) export const worksheetRateLimit = rateLimit({ - windowMs: 60 * 60 * 1000, // 1 hour window - max: 10, // Limit each IP to 10 requests per hour + windowMs: WORKSHEET_RATE_LIMIT_WINDOW_MS, + max: WORKSHEET_RATE_LIMIT_MAX_REQUESTS, message: { error: 'Too many worksheet requests', - message: 'You have exceeded the rate limit of 10 worksheets per hour. Please try again later.', - retryAfter: '1 hour', + message: `You have exceeded the rate limit of ${WORKSHEET_RATE_LIMIT_MAX_REQUESTS} worksheets per ${describeWindow(WORKSHEET_RATE_LIMIT_WINDOW_MS)}. Please try again later.`, + retryAfter: describeWindow(WORKSHEET_RATE_LIMIT_WINDOW_MS), }, standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers legacyHeaders: false, // Disable the `X-RateLimit-*` headers @@ -20,7 +47,7 @@ export const worksheetRateLimit = rateLimit({ // Progressive delay for worksheet requests to discourage rapid requests export const worksheetSlowDown = slowDown({ - windowMs: 60 * 60 * 1000, // 1 hour window + windowMs: WORKSHEET_RATE_LIMIT_WINDOW_MS, delayAfter: 3, // Allow 3 requests per hour at full speed delayMs: hits => hits * 2000, // Add 2 seconds delay for each request after the 3rd maxDelayMs: 30000, // Maximum delay of 30 seconds @@ -28,12 +55,12 @@ export const worksheetSlowDown = slowDown({ // General API rate limiting (less strict for health checks, etc.) export const generalRateLimit = rateLimit({ - windowMs: 15 * 60 * 1000, // 15 minutes - max: 100, // Limit each IP to 100 requests per 15 minutes + windowMs: GENERAL_RATE_LIMIT_WINDOW_MS, + max: GENERAL_RATE_LIMIT_MAX_REQUESTS, message: { error: 'Too many requests', message: 'You have exceeded the general rate limit. Please try again later.', - retryAfter: '15 minutes', + retryAfter: describeWindow(GENERAL_RATE_LIMIT_WINDOW_MS), }, standardHeaders: true, legacyHeaders: false, diff --git a/server/src/middleware/validation.test.ts b/server/src/middleware/validation.test.ts new file mode 100644 index 0000000..3ca8b95 --- /dev/null +++ b/server/src/middleware/validation.test.ts @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { worksheetRequestSchema } from './validation.js'; + +const valid = { gradeLevel: '3rd Grade', topic: 'Volcanoes' }; + +describe('worksheetRequestSchema', () => { + it('accepts every grade label the UI sends', () => { + const labels = [ + 'Kindergarten', + ...[ + '1st', + '2nd', + '3rd', + '4th', + '5th', + '6th', + '7th', + '8th', + '9th', + '10th', + '11th', + '12th', + ].map(g => `${g} Grade`), + ]; + for (const gradeLevel of labels) { + assert.ok(worksheetRequestSchema.safeParse({ ...valid, gradeLevel }).success, gradeLevel); + } + }); + + it('accepts bare grades and school bands', () => { + for (const gradeLevel of ['K', '7', 'Elementary', 'Middle School', 'High School']) { + assert.ok(worksheetRequestSchema.safeParse({ ...valid, gradeLevel }).success, gradeLevel); + } + }); + + it('rejects unknown grade levels', () => { + for (const gradeLevel of ['13th Grade', '99th', 'College', '']) { + assert.equal( + worksheetRequestSchema.safeParse({ ...valid, gradeLevel }).success, + false, + gradeLevel + ); + } + }); + + it('enforces topic length bounds', () => { + assert.equal(worksheetRequestSchema.safeParse({ ...valid, topic: 'ab' }).success, false); + assert.equal( + worksheetRequestSchema.safeParse({ ...valid, topic: 'a'.repeat(101) }).success, + false + ); + assert.ok(worksheetRequestSchema.safeParse({ ...valid, topic: 'The Water Cycle' }).success); + }); + + it('rejects inappropriate and spam-like topics', () => { + for (const topic of ['weapons of the civil war', 'Gambling odds', 'aaaaaaaa']) { + assert.equal(worksheetRequestSchema.safeParse({ ...valid, topic }).success, false, topic); + } + }); + + it('accepts only known complexity values', () => { + for (const complexity of ['easy', 'medium', 'hard']) { + assert.ok(worksheetRequestSchema.safeParse({ ...valid, complexity }).success, complexity); + } + assert.equal( + worksheetRequestSchema.safeParse({ ...valid, complexity: 'extreme' }).success, + false + ); + assert.ok(worksheetRequestSchema.safeParse(valid).success); + }); + + it('rejects unknown properties', () => { + assert.equal(worksheetRequestSchema.safeParse({ ...valid, model: 'gpt-4o' }).success, false); + }); +}); diff --git a/server/tsconfig.eslint.json b/server/tsconfig.eslint.json new file mode 100644 index 0000000..de3bf41 --- /dev/null +++ b/server/tsconfig.eslint.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { "noEmit": true }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/server/tsconfig.node.json b/server/tsconfig.node.json deleted file mode 100644 index 33c4d29..0000000 --- a/server/tsconfig.node.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "compilerOptions": { - "composite": true, - "skipLibCheck": true, - "module": "NodeNext", - "moduleResolution": "NodeNext", - "allowSyntheticDefaultImports": true, - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, - "esModuleInterop": true, - "resolveJsonModule": true - }, - "include": ["vite.config.*", "**/vite.config.*"], - "exclude": ["node_modules"] -} diff --git a/start-dev.js b/start-dev.js deleted file mode 100644 index 4e3df3f..0000000 --- a/start-dev.js +++ /dev/null @@ -1,56 +0,0 @@ -const { spawn } = require('child_process'); -const path = require('path'); - -console.log('🚀 Starting LearningLab in development mode...'); - -// Start the backend server -const backend = spawn('npm', ['run', 'dev'], { - cwd: path.join(__dirname, 'server'), - stdio: 'inherit', - shell: true, - env: { - ...process.env, - FORCE_COLOR: '1', - NODE_ENV: 'development' - } -}); - -// Start the frontend dev server -const frontend = spawn('npm', ['run', 'dev'], { - cwd: path.join(__dirname, 'server/client'), - stdio: 'inherit', - shell: true, - env: { - ...process.env, - FORCE_COLOR: '1', - NODE_ENV: 'development' - } -}); - -// Handle process termination -const handleExit = (signal) => { - console.log(`\n${signal}: Shutting down...`); - backend.kill(); - frontend.kill(); - process.exit(); -}; - -process.on('SIGINT', handleExit); -process.on('SIGTERM', handleExit); - -// Log process exits -backend.on('exit', (code) => { - console.log(`Backend process exited with code ${code}`); - if (code !== 0) { - frontend.kill(); - process.exit(code); - } -}); - -frontend.on('exit', (code) => { - console.log(`Frontend process exited with code ${code}`); - if (code !== 0) { - backend.kill(); - process.exit(code); - } -}); diff --git a/test/cloudflare-function.test.mjs b/test/cloudflare-function.test.mjs new file mode 100644 index 0000000..5e9476b --- /dev/null +++ b/test/cloudflare-function.test.mjs @@ -0,0 +1,197 @@ +import assert from "node:assert/strict"; +import { afterEach, describe, it } from "node:test"; + +import { + onRequestOptions, + onRequestPost, +} from "../functions/api/generate-worksheet.js"; + +const ORIGIN = "https://learninglab.example"; +const WORKSHEET = { + title: "Volcanoes", + passage: "Volcanoes are openings in the crust.", + multipleChoice: [ + { + question: "What is a volcano?", + options: ["A", "B", "C", "D"], + answer: "A", + }, + ], + shortAnswer: [{ question: "Why do volcanoes erupt?", answer: "Pressure." }], +}; + +const memoryKv = () => { + const store = new Map(); + return { + get: async (key) => store.get(key) ?? null, + put: async (key, value) => void store.set(key, value), + }; +}; + +const request = (body, headers = {}) => + new Request(`${ORIGIN}/api/generate-worksheet`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "CF-Connecting-IP": "203.0.113.7", + ...headers, + }, + body: typeof body === "string" ? body : JSON.stringify(body), + }); + +const run = (req, env = {}) => { + const pending = []; + const context = { + request: req, + env: { OPENAI_API_KEY: "test-key", RATE_LIMIT_KV: memoryKv(), ...env }, + waitUntil: (p) => pending.push(p), + }; + return onRequestPost(context).then(async (res) => { + await Promise.all(pending); + return { res, context }; + }); +}; + +const realFetch = globalThis.fetch; +const stubOpenAI = (handler) => { + globalThis.fetch = async (url, init) => handler(new Request(url, init)); +}; + +afterEach(() => { + globalThis.fetch = realFetch; +}); + +describe("functions/api/generate-worksheet", () => { + it("rejects invalid request bodies before calling OpenAI", async () => { + let calls = 0; + stubOpenAI(() => { + calls += 1; + throw new Error("should not be called"); + }); + + for (const body of [ + { gradeLevel: "99th Grade", topic: "Volcanoes" }, + { gradeLevel: "3rd Grade", topic: "ab" }, + { gradeLevel: "3rd Grade", topic: "weapons" }, + { gradeLevel: "3rd Grade", topic: "Volcanoes", complexity: "extreme" }, + {}, + ]) { + const { res } = await run(request(body)); + assert.equal(res.status, 400, JSON.stringify(body)); + } + assert.equal(calls, 0); + }); + + it("returns the generated worksheet with rate-limit headers", async () => { + stubOpenAI(async (req) => { + assert.equal(req.headers.get("authorization"), "Bearer test-key"); + return Response.json({ + id: "cmpl", + object: "chat.completion", + choices: [ + { + index: 0, + message: { role: "assistant", content: JSON.stringify(WORKSHEET) }, + }, + ], + }); + }); + + const { res } = await run( + request({ gradeLevel: "3rd Grade", topic: "Volcanoes" }), + ); + assert.equal(res.status, 200); + assert.deepEqual(await res.json(), WORKSHEET); + assert.equal(res.headers.get("RateLimit-Limit"), "10"); + assert.equal(res.headers.get("RateLimit-Remaining"), "9"); + }); + + it("masks upstream failures", async () => { + stubOpenAI( + async () => new Response("secret upstream detail", { status: 401 }), + ); + + const { res } = await run( + request({ gradeLevel: "3rd Grade", topic: "Volcanoes" }), + ); + assert.equal(res.status, 500); + const body = await res.json(); + assert.equal(body.error, "Failed to generate worksheet"); + assert.doesNotMatch(JSON.stringify(body), /secret upstream detail/); + }); + + it("limits each IP to 10 worksheets per window", async () => { + stubOpenAI(async () => + Response.json({ + id: "cmpl", + object: "chat.completion", + choices: [ + { + index: 0, + message: { role: "assistant", content: JSON.stringify(WORKSHEET) }, + }, + ], + }), + ); + const kv = memoryKv(); + for (let i = 0; i < 10; i += 1) { + const { res } = await run( + request({ gradeLevel: "3rd Grade", topic: "Volcanoes" }), + { + RATE_LIMIT_KV: kv, + }, + ); + assert.equal(res.status, 200, `request ${i + 1}`); + } + const { res } = await run( + request({ gradeLevel: "3rd Grade", topic: "Volcanoes" }), + { + RATE_LIMIT_KV: kv, + }, + ); + assert.equal(res.status, 429); + assert.equal(res.headers.get("RateLimit-Remaining"), "0"); + assert.ok(res.headers.get("Retry-After")); + }); + + it("allows same-origin and allow-listed origins only", async () => { + const body = { gradeLevel: "3rd Grade", topic: "Volcanoes" }; + + const foreign = await run( + request(body, { Origin: "https://evil.example" }), + ); + assert.equal(foreign.res.status, 403); + + const preflight = await onRequestOptions({ + request: new Request(`${ORIGIN}/api/generate-worksheet`, { + method: "OPTIONS", + headers: { Origin: "https://evil.example" }, + }), + env: {}, + }); + assert.equal(preflight.status, 403); + + const listed = await onRequestOptions({ + request: new Request(`${ORIGIN}/api/generate-worksheet`, { + method: "OPTIONS", + headers: { Origin: "https://partner.example" }, + }), + env: { ALLOWED_ORIGINS: "https://partner.example" }, + }); + assert.equal(listed.status, 204); + assert.equal( + listed.headers.get("Access-Control-Allow-Origin"), + "https://partner.example", + ); + + const same = await onRequestOptions({ + request: new Request(`${ORIGIN}/api/generate-worksheet`, { + method: "OPTIONS", + headers: { Origin: ORIGIN }, + }), + env: {}, + }); + assert.equal(same.status, 204); + assert.equal(same.headers.get("Access-Control-Allow-Origin"), ORIGIN); + }); +}); diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index 1a3391f..0000000 --- a/tsconfig.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "module": "CommonJS", - "moduleResolution": "Node", - "esModuleInterop": true, - "allowSyntheticDefaultImports": true, - "strict": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "outDir": "./dist", - "rootDir": ".", - "baseUrl": ".", - "paths": { - "@/*": ["./src/*"] - }, - "types": ["node"], - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true - }, - "include": ["api/**/*.ts"], - "exclude": ["node_modules", "**/*.test.ts", "**/*.spec.ts", "dist", "learninglab-nextjs", "server", "ui"], - "ts-node": { - "esm": true, - "experimentalSpecifierResolution": "node" - } -} diff --git a/vercel.json b/vercel.json deleted file mode 100644 index 94b9c1e..0000000 --- a/vercel.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "functions": { - "api/generate-worksheet.js": { - "runtime": "nodejs22.x" - } - }, - "env": { - "OPENAI_API_KEY": "@openai_api_key" - } -}