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"
- }
-}