feat: auth, persistence, and AI integration
- Cookie-based auth via backend proxy (httpOnly JWTs) - Supabase Postgres persistence for sessions/messages/profiles + RLS - Fix cross-user session leak (token cache keyed by full token, not 50-char prefix) - Fix missing table grants (42501) via migration; auto-provision profiles on user creation - Chat validation, ownership checks, rate limiting, /api/chat/sessions route ordering - Frontend auth-state reset + credentials include - OpenRouter AI provider (OpenAI-compatible base URL, reasoning disabled) - Tests: chatValidation, appState
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
# AI Provider: "openai" | "anthropic" | "google"
|
||||
AI_PROVIDER=openai
|
||||
|
||||
# OpenAI
|
||||
# OpenAI (OpenAI-compatible; set OPENAI_BASE_URL to use OpenRouter or another provider)
|
||||
OPENAI_API_KEY=sk-your-key-here
|
||||
OPENAI_MODEL=gpt-4o
|
||||
OPENAI_BASE_URL=https://openrouter.ai/api/v1
|
||||
OPENAI_MODEL=qwen/qwen3.7-flash
|
||||
|
||||
# Anthropic (optional)
|
||||
ANTHROPIC_API_KEY=sk-ant-your-key-here
|
||||
|
||||
Generated
+215
@@ -8,12 +8,110 @@
|
||||
"name": "padhle-backend",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@supabase/supabase-js": "^2.112.3",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-sqlite3": "^13.0.3",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.6.0",
|
||||
"express": "^5.1.0",
|
||||
"express-rate-limit": "^8.6.2",
|
||||
"jose": "^6.2.9",
|
||||
"openai": "^5.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/auth-js": {
|
||||
"version": "2.112.3",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.112.3.tgz",
|
||||
"integrity": "sha512-NA0rsgAlWZPvbhw8aUdmgfpHVgUAcd8zK5ov43l++o1bLIPXZhRiAlRobhwF5AatQuovpqxsMH50F4oyyV4XZw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/functions-js": {
|
||||
"version": "2.112.3",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.112.3.tgz",
|
||||
"integrity": "sha512-gfv481mTOVWtZIJgXupxZpni2V2UWPf6jeF/jOK7HdMHdH+mt6sU0sHHwf0POsPip8ltlulu9OUHgwVzl5ddRw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/phoenix": {
|
||||
"version": "0.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.5.tgz",
|
||||
"integrity": "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@supabase/postgrest-js": {
|
||||
"version": "2.112.3",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.112.3.tgz",
|
||||
"integrity": "sha512-+Mf6uCpzr00bqxwX8hTK2X2L9eAL/1vuOjdEjx6upz9ulb0RmQT16XeU/JkMUlVHw/B46ZnPa2busY4Kd9YCzw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/realtime-js": {
|
||||
"version": "2.112.3",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.112.3.tgz",
|
||||
"integrity": "sha512-E6wljXWs7DUOloyIB69i3YFInWE6IyCvgTAbQ0KYxOHv26FdA1KzEXTuzxrYEdf70t406Z9BRwUlGyclGF2FXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/phoenix": "0.4.5",
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/storage-js": {
|
||||
"version": "2.112.3",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.112.3.tgz",
|
||||
"integrity": "sha512-oSK61tzlUvg+BWPqpKQCu9qqonsO26btaoAR9D6Gest2aj7xUqToj9rKyaoYOJczkhg9BjqA1REbYy9tPI4bDA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"iceberg-js": "^0.8.1",
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/supabase-js": {
|
||||
"version": "2.112.3",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.112.3.tgz",
|
||||
"integrity": "sha512-Jv1bxVQmEJNkjvPEhFaKjPzsh+Ozyew6lWGD+SoYcsclDEP1z7yEvKvfUQfzy0DkxRIQnZNxmmWtAzw5XLTQoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/auth-js": "2.112.3",
|
||||
"@supabase/functions-js": "2.112.3",
|
||||
"@supabase/postgrest-js": "2.112.3",
|
||||
"@supabase/realtime-js": "2.112.3",
|
||||
"@supabase/storage-js": "2.112.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@opentelemetry/api": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||
@@ -27,6 +125,32 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/bcrypt": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
|
||||
"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-addon-api": "^8.3.0",
|
||||
"node-gyp-build": "^4.8.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/better-sqlite3": {
|
||||
"version": "13.0.3",
|
||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz",
|
||||
"integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-addon-api": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
|
||||
@@ -133,6 +257,25 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-parser": {
|
||||
"version": "1.4.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
|
||||
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "0.7.2",
|
||||
"cookie-signature": "1.0.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-parser/node_modules/cookie-signature": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie-signature": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
|
||||
@@ -314,6 +457,25 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.6.2",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.2.tgz",
|
||||
"integrity": "sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.4.3",
|
||||
"ip-address": "^10.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/express-rate-limit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": ">= 4.11"
|
||||
}
|
||||
},
|
||||
"node_modules/finalhandler": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
|
||||
@@ -455,6 +617,15 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/iceberg-js": {
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
|
||||
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.7.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
||||
@@ -477,6 +648,15 @@
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz",
|
||||
"integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
@@ -492,6 +672,15 @@
|
||||
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "6.2.9",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.9.tgz",
|
||||
"integrity": "sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -566,6 +755,26 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "8.9.2",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz",
|
||||
"integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18 || ^20 || >= 21"
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp-build": {
|
||||
"version": "4.8.4",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
|
||||
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"node-gyp-build": "bin.js",
|
||||
"node-gyp-build-optional": "optional.js",
|
||||
"node-gyp-build-test": "build-test.js"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
@@ -868,6 +1077,12 @@
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
|
||||
|
||||
@@ -8,9 +8,15 @@
|
||||
"start": "node src/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@supabase/supabase-js": "^2.112.3",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-sqlite3": "^13.0.3",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.6.0",
|
||||
"express": "^5.1.0",
|
||||
"openai": "^5.11.0",
|
||||
"dotenv": "^16.6.0"
|
||||
"express-rate-limit": "^8.6.2",
|
||||
"jose": "^6.2.9",
|
||||
"openai": "^5.11.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const SUPABASE_URL = process.env.SUPABASE_URL || 'http://127.0.0.1:54321';
|
||||
const SUPABASE_SERVICE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY || 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz';
|
||||
|
||||
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_KEY);
|
||||
|
||||
async function executeSql(sql) {
|
||||
try {
|
||||
const { error } = await supabase.rpc('sql', { query: sql });
|
||||
if (error) throw error;
|
||||
} catch (err) {
|
||||
// Try with pg_query if available, otherwise use REST fallback
|
||||
console.error('SQL execution fallback:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function applyMigration() {
|
||||
console.log('🚀 Applying database migration...');
|
||||
|
||||
const migrationFile = path.join(__dirname, '../supabase/migrations/20260819064500_create_sessions_messages.sql');
|
||||
const sql = fs.readFileSync(migrationFile, 'utf-8');
|
||||
|
||||
const statements = sql.split(';').map(s => s.trim()).filter(s => s && !s.startsWith('--'));
|
||||
|
||||
for (const stmt of statements) {
|
||||
try {
|
||||
console.log(`✓ ${stmt.substring(0, 60)}...`);
|
||||
await executeSql(stmt);
|
||||
} catch (err) {
|
||||
console.error(`✗ Failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('✅ Migration complete!');
|
||||
}
|
||||
|
||||
applyMigration().catch(err => {
|
||||
console.error('❌ Migration failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
+12
-4
@@ -1,8 +1,12 @@
|
||||
import dotenv from "dotenv";
|
||||
import cors from "cors";
|
||||
import cookieParser from "cookie-parser";
|
||||
import express from "express";
|
||||
import { chatLimiter, sessionsLimiter } from "./middleware/rateLimiter.js";
|
||||
import chatRoutes from "./routes/chat.js";
|
||||
import sessionRoutes from "./routes/sessions.js";
|
||||
import authRoutes from "./routes/auth.js";
|
||||
import supabaseAuth from "./middleware/supabaseAuth.js";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
@@ -11,17 +15,21 @@ const PORT = process.env.PORT || 3001;
|
||||
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173";
|
||||
|
||||
// Middleware
|
||||
app.use(cors({ origin: CORS_ORIGIN, credentials: true }));
|
||||
app.use(cookieParser());
|
||||
app.use(express.json());
|
||||
app.use(cors({ origin: CORS_ORIGIN, credentials: true }));
|
||||
|
||||
// Health check
|
||||
app.get("/health", (_req, res) => {
|
||||
res.json({ status: "ok", provider: process.env.AI_PROVIDER });
|
||||
});
|
||||
|
||||
// Routes
|
||||
app.use("/api/chat", chatRoutes);
|
||||
app.use("/api/sessions", sessionRoutes);
|
||||
// Auth routes — public endpoints (signup, signin, signout, me)
|
||||
app.use("/api/auth", authRoutes);
|
||||
|
||||
// Protected routes — require valid JWT in httpOnly cookie
|
||||
app.use("/api/chat", chatLimiter, supabaseAuth, chatRoutes);
|
||||
app.use("/api/sessions", sessionsLimiter, supabaseAuth, sessionRoutes);
|
||||
|
||||
// 404 handler
|
||||
app.use((_req, res) => {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Cookie parsing middleware.
|
||||
* Express 5 uses built-in cookie parsing, but we add this for explicit handling.
|
||||
*/
|
||||
import cookieParser from "cookie-parser";
|
||||
|
||||
export default function setupCookieParser(app) {
|
||||
app.use(cookieParser());
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import rateLimit from 'express-rate-limit';
|
||||
|
||||
// Auth limiter: 5 requests per 15 minutes per IP
|
||||
export const authLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 5, // 5 requests per window per IP
|
||||
message: { error: 'Too many auth attempts. Try again later.' },
|
||||
standardHeaders: false,
|
||||
skip: (req) => process.env.NODE_ENV !== 'production' && req.ip === '::1', // Skip localhost in dev
|
||||
});
|
||||
|
||||
// Chat limiter: 30 requests per minute per IP
|
||||
export const chatLimiter = rateLimit({
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
max: 30, // 30 requests per minute
|
||||
message: { error: 'Rate limit exceeded. Try again later.' },
|
||||
standardHeaders: false,
|
||||
});
|
||||
|
||||
// Sessions limiter: 20 requests per minute per IP
|
||||
export const sessionsLimiter = rateLimit({
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
max: 20, // 20 requests per minute
|
||||
message: { error: 'Rate limit exceeded. Try again later.' },
|
||||
standardHeaders: false,
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Supabase JWT verification middleware.
|
||||
* Reads the JWT from the httpOnly cookie set by /api/auth/signin.
|
||||
* Calls Supabase Auth server directly to verify the token.
|
||||
* If verified, attaches { uid, email } to req.user.
|
||||
*/
|
||||
import dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
|
||||
const SUPABASE_URL = process.env.SUPABASE_URL;
|
||||
const PUB_KEY = process.env.SUPABASE_PUBLISHABLE_KEY;
|
||||
|
||||
// Token cache: token -> { uid, email, expires } (CVE-2026-007)
|
||||
// Keyed by the FULL token. A truncated prefix (e.g. first 50 chars) collides
|
||||
// across users because every JWT from the same instance shares the header and
|
||||
// initial claims, which would return one user's identity for another's request.
|
||||
const tokenCache = new Map();
|
||||
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
/**
|
||||
* Verify a JWT token against Supabase Auth server.
|
||||
* Returns { uid, email } on success, null on failure.
|
||||
* Results are cached to avoid hitting Supabase on every request.
|
||||
*/
|
||||
export async function verifyToken(token) {
|
||||
if (!token || !SUPABASE_URL || !PUB_KEY) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check cache first. The token itself is the unique identity — no truncation.
|
||||
const cached = tokenCache.get(token);
|
||||
if (cached && Date.now() < cached.expires) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 3000); // CVE-2026-006: timeout
|
||||
|
||||
const res = await fetch(`${SUPABASE_URL}/auth/v1/user`, {
|
||||
headers: {
|
||||
apikey: PUB_KEY,
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (res.status === 200) {
|
||||
const user = await res.json();
|
||||
const result = { uid: user.id, email: user.email };
|
||||
// Cache the result (CVE-2026-007)
|
||||
tokenCache.set(token, { ...result, expires: Date.now() + CACHE_TTL });
|
||||
return result;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (err) {
|
||||
if (err.name !== "AbortError") {
|
||||
console.error("JWT verification error:", err.message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Express middleware. Returns 401 if token is missing or invalid.
|
||||
* Reads token from cookie "padhle.token".
|
||||
* Attaches req.user = { uid, email } on success.
|
||||
*/
|
||||
export default function supabaseAuth(req, res, next) {
|
||||
const token = req.cookies?.["padhle.token"];
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: "Missing or invalid authorization" });
|
||||
}
|
||||
|
||||
verifyToken(token).then((user) => {
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: "Invalid or expired token" });
|
||||
}
|
||||
|
||||
req.user = user;
|
||||
next();
|
||||
}).catch(next);
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional auth middleware — checks for token but doesn't block if missing.
|
||||
* Useful for routes that support both authenticated and anonymous access.
|
||||
*/
|
||||
export function optionalAuth(req, res, next) {
|
||||
const token = req.cookies?.["padhle.token"];
|
||||
if (token) {
|
||||
verifyToken(token).then((user) => {
|
||||
if (user) req.user = user;
|
||||
next();
|
||||
}).catch(next);
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Cookie-based auth routes.
|
||||
* All endpoints call Supabase Auth API directly — the backend holds the tokens.
|
||||
* Frontend receives httpOnly cookies, never sees raw tokens.
|
||||
*/
|
||||
import express from "express";
|
||||
import dotenv from "dotenv";
|
||||
import { authLimiter } from "../middleware/rateLimiter.js";
|
||||
dotenv.config();
|
||||
|
||||
const SUPABASE_URL = process.env.SUPABASE_URL;
|
||||
const API_KEY = process.env.SUPABASE_PUBLISHABLE_KEY;
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* POST /api/auth/signup
|
||||
* Creates a new account via Supabase Auth.
|
||||
* Returns 200 with user info + httpOnly cookie.
|
||||
*/
|
||||
router.post("/signup", authLimiter, async (req, res) => {
|
||||
const { email, password } = req.body;
|
||||
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({ error: "Email and password are required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const res_supabase = await fetch(`${SUPABASE_URL}/auth/v1/signup`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
apikey: API_KEY,
|
||||
},
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
const data = await res_supabase.json();
|
||||
|
||||
if (!res_supabase.ok) {
|
||||
// Log raw error for debugging; return generic message to client (CVE-2026-005)
|
||||
console.error("Signup error:", data.error_description || data.message);
|
||||
return res.status(res_supabase.status).json({ error: "Sign up failed" });
|
||||
}
|
||||
|
||||
// Set token in httpOnly cookie
|
||||
res.cookie("padhle.token", data.access_token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: data.expires_in * 1000,
|
||||
path: "/",
|
||||
});
|
||||
|
||||
// Set user metadata cookie (safe, non-sensitive)
|
||||
res.cookie("padhle.user", JSON.stringify({ uid: data.user.id, email: data.user.email }), {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: data.expires_in * 1000,
|
||||
path: "/",
|
||||
});
|
||||
|
||||
return res.json({
|
||||
user: { uid: data.user.id, email: data.user.email },
|
||||
message: "Account created successfully",
|
||||
});
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/auth/signin
|
||||
* Authenticates user via Supabase Auth.
|
||||
* Returns 200 with user info + httpOnly cookie.
|
||||
*/
|
||||
router.post("/signin", authLimiter, async (req, res) => {
|
||||
const { email, password } = req.body;
|
||||
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({ error: "Email and password are required" });
|
||||
}
|
||||
|
||||
try {
|
||||
const res_supabase = await fetch(`${SUPABASE_URL}/auth/v1/token?grant_type=password`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
apikey: API_KEY,
|
||||
},
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
const data = await res_supabase.json();
|
||||
|
||||
if (!res_supabase.ok) {
|
||||
// Log raw error for debugging; return generic message to client (CVE-2026-005)
|
||||
console.error("Signin error:", data.error_description || data.message);
|
||||
return res.status(res_supabase.status).json({ error: "Sign in failed" });
|
||||
}
|
||||
|
||||
// Set token in httpOnly cookie
|
||||
res.cookie("padhle.token", data.access_token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: data.expires_in * 1000,
|
||||
path: "/",
|
||||
});
|
||||
|
||||
// Set user metadata cookie (safe, non-sensitive)
|
||||
res.cookie("padhle.user", JSON.stringify({ uid: data.user.id, email: data.user.email }), {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: data.expires_in * 1000,
|
||||
path: "/",
|
||||
});
|
||||
|
||||
return res.json({
|
||||
user: { uid: data.user.id, email: data.user.email },
|
||||
message: "Signed in successfully",
|
||||
});
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/auth/signout
|
||||
* Invalidates the user's session via Supabase Auth.
|
||||
* Clears all cookies.
|
||||
*/
|
||||
router.post("/signout", authLimiter, async (req, res) => {
|
||||
try {
|
||||
const token = req.cookies?.["padhle.token"] || req.headers.authorization?.split("Bearer ")[1];
|
||||
|
||||
if (token) {
|
||||
// Call Supabase logout to revoke session server-side
|
||||
await fetch(`${SUPABASE_URL}/auth/v1/logout`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
apikey: API_KEY,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Don't fail if logout fails — still clear cookies
|
||||
}
|
||||
|
||||
// Clear cookies
|
||||
res.clearCookie("padhle.token", { path: "/" });
|
||||
res.clearCookie("padhle.user", { path: "/" });
|
||||
|
||||
return res.json({ message: "Signed out successfully" });
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/auth/me
|
||||
* Returns current user info from the httpOnly cookie.
|
||||
*/
|
||||
router.get("/me", (req, res) => {
|
||||
try {
|
||||
const userCookie = req.cookies?.["padhle.user"];
|
||||
if (!userCookie) {
|
||||
return res.status(401).json({ error: "Not authenticated" });
|
||||
}
|
||||
|
||||
const user = JSON.parse(userCookie);
|
||||
return res.json({ user });
|
||||
} catch {
|
||||
return res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
+65
-98
@@ -1,65 +1,35 @@
|
||||
import express from "express";
|
||||
import { streamChatResponse } from "../services/ai.js";
|
||||
import { createSession, addMessage, getMessages, getSession, listSessions } from "../stores/sessionStore.js";
|
||||
import { createSession, addMessage, getMessages, getSession, listSessions } from "../services/db.js";
|
||||
import { isOwnedSession, validateChatInput } from "./chatValidation.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* POST /api/chat — Send a message and stream back an AI response.
|
||||
*
|
||||
* Request body:
|
||||
* {
|
||||
* text: "What is photosynthesis?",
|
||||
* chatId: "optional_existing_session_id",
|
||||
* grade: "Grade 10",
|
||||
* subject: "Biology",
|
||||
* chapter: "Nutrition in Plants"
|
||||
* }
|
||||
*/
|
||||
router.post("/", async (req, res) => {
|
||||
const { text, chatId, grade, subject, chapter } = req.body;
|
||||
const userId = req.user.uid;
|
||||
const validation = validateChatInput({ text, grade, subject });
|
||||
if (!validation.ok) return res.status(400).json({ error: validation.error });
|
||||
|
||||
// Validate input
|
||||
if (!text || !text.trim()) {
|
||||
return res.status(400).json({ error: "Message text is required" });
|
||||
}
|
||||
|
||||
// Resolve or create session
|
||||
let session;
|
||||
if (chatId) {
|
||||
session = getSession(chatId);
|
||||
if (!session) {
|
||||
return res.status(404).json({ error: "Session not found" });
|
||||
}
|
||||
} else {
|
||||
session = createSession(grade, subject, chapter);
|
||||
}
|
||||
|
||||
const currentChatId = session.id;
|
||||
|
||||
// Get conversation history
|
||||
const chatMessages = getMessages(currentChatId);
|
||||
|
||||
// Add user message to history
|
||||
const userMsg = {
|
||||
id: `user_${Date.now()}`,
|
||||
role: "user",
|
||||
text: text.trim(),
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
addMessage(currentChatId, userMsg);
|
||||
|
||||
// Set SSE headers
|
||||
res.setHeader("Content-Type", "text/event-stream");
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
res.setHeader("Connection", "keep-alive");
|
||||
res.setHeader("X-Accel-Buffering", "no");
|
||||
|
||||
let assistantText = "";
|
||||
let errorOccurred = false;
|
||||
|
||||
// Stream the AI response
|
||||
try {
|
||||
let session;
|
||||
if (chatId) {
|
||||
session = await getSession(chatId);
|
||||
if (!isOwnedSession(session, userId)) {
|
||||
return res.status(404).json({ error: "Session not found" });
|
||||
}
|
||||
} else {
|
||||
session = await createSession(userId, grade, subject, chapter);
|
||||
}
|
||||
|
||||
const currentChatId = session.id;
|
||||
const chatMessages = await getMessages(currentChatId);
|
||||
res.setHeader("Content-Type", "text/event-stream");
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
res.setHeader("Connection", "keep-alive");
|
||||
res.setHeader("X-Accel-Buffering", "no");
|
||||
|
||||
let assistantText = "";
|
||||
await streamChatResponse(
|
||||
chatMessages,
|
||||
grade || session.grade,
|
||||
@@ -67,66 +37,63 @@ router.post("/", async (req, res) => {
|
||||
chapter || session.chapter,
|
||||
(chunk) => {
|
||||
assistantText += chunk;
|
||||
// Send chunk as SSE event
|
||||
res.write(`data: ${JSON.stringify({ type: "chunk", content: chunk })}\n\n`);
|
||||
const safe = JSON.stringify({ type: "chunk", content: chunk })
|
||||
.replace(/\n/g, "\\n")
|
||||
.replace(/\u2028/g, "\\u2028")
|
||||
.replace(/\u2029/g, "\\u2029");
|
||||
res.write(`data: ${safe}\n\n`);
|
||||
},
|
||||
(err) => {
|
||||
errorOccurred = true;
|
||||
res.write(`data: ${JSON.stringify({ type: "error", message: err })}\n\n`);
|
||||
() => {
|
||||
res.write(`data: ${JSON.stringify({ type: "error", message: "AI service error" })}\n\n`);
|
||||
res.end();
|
||||
}
|
||||
);
|
||||
|
||||
// Send end event
|
||||
res.write(`data: ${JSON.stringify({ type: "done" })}\n\n`);
|
||||
|
||||
// Save assistant message to history
|
||||
const assistantMsg = {
|
||||
id: `assistant_${Date.now()}`,
|
||||
role: "assistant",
|
||||
text: assistantText,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
addMessage(currentChatId, assistantMsg);
|
||||
await addMessage(currentChatId, "user", text.trim());
|
||||
await addMessage(currentChatId, "assistant", assistantText);
|
||||
} catch (err) {
|
||||
res.write(`data: ${JSON.stringify({ type: "error", message: err.message })}\n\n`);
|
||||
console.error("Chat error:", err.message);
|
||||
if (!res.headersSent) res.status(500).json({ error: "Internal server error" });
|
||||
else res.write(`data: ${JSON.stringify({ type: "error", message: "Internal server error" })}\n\n`);
|
||||
} finally {
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/chat/:chatId — Get conversation messages for a session
|
||||
*/
|
||||
router.get("/:chatId", (req, res) => {
|
||||
const { chatId } = req.params;
|
||||
const session = getSession(chatId);
|
||||
|
||||
if (!session) {
|
||||
return res.status(404).json({ error: "Session not found" });
|
||||
// Static route must precede /:chatId.
|
||||
router.get("/sessions", async (req, res) => {
|
||||
try {
|
||||
res.json(await listSessions(req.user.uid));
|
||||
} catch (err) {
|
||||
console.error("List sessions error:", err.message);
|
||||
res.status(500).json({ error: "Failed to load sessions" });
|
||||
}
|
||||
|
||||
const msgHistory = getMessages(chatId);
|
||||
|
||||
res.json({
|
||||
session: {
|
||||
id: session.id,
|
||||
grade: session.grade,
|
||||
subject: session.subject,
|
||||
chapter: session.chapter,
|
||||
createdAt: session.createdAt,
|
||||
updatedAt: session.updatedAt,
|
||||
},
|
||||
messages: msgHistory,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/chat/sessions — List all chat sessions
|
||||
*/
|
||||
router.get("/sessions", (_req, res) => {
|
||||
const sessions = listSessions();
|
||||
res.json(sessions);
|
||||
router.get("/:chatId", async (req, res) => {
|
||||
try {
|
||||
const { chatId } = req.params;
|
||||
const session = await getSession(chatId);
|
||||
if (!isOwnedSession(session, req.user.uid)) {
|
||||
return res.status(404).json({ error: "Session not found" });
|
||||
}
|
||||
const msgHistory = await getMessages(chatId);
|
||||
res.json({
|
||||
session: {
|
||||
id: session.id,
|
||||
grade: session.grade,
|
||||
subject: session.subject,
|
||||
chapter: session.chapter,
|
||||
createdAt: session.created_at,
|
||||
updatedAt: session.updated_at,
|
||||
},
|
||||
messages: msgHistory,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Get chat error:", err.message);
|
||||
res.status(500).json({ error: "Failed to load session" });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
const VALID_GRADES = new Set([
|
||||
"Grade 6", "Grade 7", "Grade 8", "Grade 9", "Grade 10", "Grade 11", "Grade 12",
|
||||
"General", "Choose standard",
|
||||
]);
|
||||
const VALID_SUBJECTS = new Set([
|
||||
"math", "science", "history", "language", "english", "geography", "General", "choose-subject",
|
||||
]);
|
||||
|
||||
export function validateChatInput({ text, grade, subject }) {
|
||||
if (!text || !text.trim()) return { ok: false, error: "Message text is required" };
|
||||
if (text.length > 10000) return { ok: false, error: "Message too long (max 10000 characters)" };
|
||||
if (grade && !VALID_GRADES.has(grade)) return { ok: false, error: "Invalid grade" };
|
||||
if (subject && !VALID_SUBJECTS.has(subject)) return { ok: false, error: "Invalid subject" };
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export function isOwnedSession(session, userId) {
|
||||
return Boolean(session && session.user_id === userId);
|
||||
}
|
||||
@@ -1,58 +1,100 @@
|
||||
import express from "express";
|
||||
import { listSessions, getSession, deleteSession, clearMessages, createSession } from "../stores/sessionStore.js";
|
||||
import { listSessions, getSession, deleteSession, clearMessages, createSession } from "../services/db.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* GET /api/sessions — List all sessions
|
||||
* GET /api/sessions — List only the authenticated user's sessions
|
||||
*/
|
||||
router.get("/", (_req, res) => {
|
||||
const sessions = listSessions();
|
||||
res.json(sessions);
|
||||
router.get("/", async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.uid;
|
||||
const sessions = await listSessions(userId);
|
||||
res.json(sessions);
|
||||
} catch (err) {
|
||||
console.error("List sessions error:", err.message);
|
||||
res.status(500).json({ error: "Failed to load sessions" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/sessions — Create a new session
|
||||
*/
|
||||
router.post("/", (req, res) => {
|
||||
const { grade, subject, chapter } = req.body;
|
||||
const session = createSession(grade, subject, chapter);
|
||||
res.status(201).json(session);
|
||||
router.post("/", async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.uid;
|
||||
const { grade, subject, chapter } = req.body;
|
||||
const session = await createSession(userId, grade, subject, chapter);
|
||||
res.status(201).json(session);
|
||||
} catch (err) {
|
||||
console.error("Create session error:", err.message);
|
||||
res.status(500).json({ error: "Failed to create session" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/sessions/:id — Get a single session
|
||||
*/
|
||||
router.get("/:id", (req, res) => {
|
||||
const session = getSession(req.params.id);
|
||||
if (!session) {
|
||||
return res.status(404).json({ error: "Session not found" });
|
||||
router.get("/:id", async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.uid;
|
||||
const session = await getSession(req.params.id);
|
||||
|
||||
if (!session) {
|
||||
return res.status(404).json({ error: "Session not found" });
|
||||
}
|
||||
|
||||
// Ensure the session belongs to the authenticated user
|
||||
if (session.user_id !== userId) {
|
||||
return res.status(404).json({ error: "Session not found" });
|
||||
}
|
||||
|
||||
res.json(session);
|
||||
} catch (err) {
|
||||
console.error("Get session error:", err.message);
|
||||
res.status(500).json({ error: "Failed to load session" });
|
||||
}
|
||||
res.json(session);
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/sessions/:id — Delete a session
|
||||
* DELETE /api/sessions/:id — Delete a session (only if owned by user)
|
||||
*/
|
||||
router.delete("/:id", (req, res) => {
|
||||
const deleted = deleteSession(req.params.id);
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ error: "Session not found" });
|
||||
router.delete("/:id", async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.uid;
|
||||
const session = await getSession(req.params.id);
|
||||
|
||||
if (!session || session.user_id !== userId) {
|
||||
return res.status(404).json({ error: "Session not found" });
|
||||
}
|
||||
|
||||
await deleteSession(req.params.id);
|
||||
res.json({ message: "Session deleted", id: req.params.id });
|
||||
} catch (err) {
|
||||
console.error("Delete session error:", err.message);
|
||||
res.status(500).json({ error: "Failed to delete session" });
|
||||
}
|
||||
res.json({ message: "Session deleted", id: req.params.id });
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/sessions/:id/clear — Clear messages but keep session
|
||||
*/
|
||||
router.patch("/:id/clear", (req, res) => {
|
||||
const { id } = req.params;
|
||||
const session = getSession(id);
|
||||
if (!session) {
|
||||
return res.status(404).json({ error: "Session not found" });
|
||||
router.patch("/:id/clear", async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.uid;
|
||||
const { id } = req.params;
|
||||
const session = await getSession(id);
|
||||
|
||||
if (!session || session.user_id !== userId) {
|
||||
return res.status(404).json({ error: "Session not found" });
|
||||
}
|
||||
|
||||
await clearMessages(id);
|
||||
res.json({ message: "Messages cleared", id });
|
||||
} catch (err) {
|
||||
console.error("Clear messages error:", err.message);
|
||||
res.status(500).json({ error: "Failed to clear messages" });
|
||||
}
|
||||
clearMessages(id);
|
||||
res.json({ message: "Messages cleared", id });
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -5,9 +5,10 @@ dotenv.config();
|
||||
|
||||
const provider = process.env.AI_PROVIDER || "openai";
|
||||
|
||||
// --- OpenAI client ---
|
||||
// --- OpenAI client (OpenAI-compatible; baseURL lets us use OpenRouter etc.) ---
|
||||
const openai = new OpenAI({
|
||||
apiKey: process.env.OPENAI_API_KEY,
|
||||
baseURL: process.env.OPENAI_BASE_URL || undefined,
|
||||
});
|
||||
|
||||
// --- Anthropic (via OpenAI SDK compatible endpoint or raw) ---
|
||||
@@ -59,11 +60,17 @@ function buildMessages(chatMessages, grade, subject, chapter) {
|
||||
* Call OpenAI and stream the response via a callback.
|
||||
*/
|
||||
async function streamOpenai(messages, onChunk, onError) {
|
||||
// OpenRouter reasoning models stream thinking in delta.reasoning while
|
||||
// delta.content stays empty until thinking finishes — so the client sees a
|
||||
// long blank gap. Disable reasoning when talking to OpenRouter so content
|
||||
// streams immediately (plain OpenAI rejects the "reasoning" param).
|
||||
const isOpenRouter = (process.env.OPENAI_BASE_URL || "").includes("openrouter.ai");
|
||||
const stream = await openai.chat.completions.create({
|
||||
model: process.env.OPENAI_MODEL || "gpt-4o",
|
||||
messages,
|
||||
stream: true,
|
||||
max_tokens: 2048,
|
||||
...(isOpenRouter ? { reasoning: { enabled: false } } : {}),
|
||||
});
|
||||
|
||||
let fullResponse = "";
|
||||
@@ -148,7 +155,9 @@ async function streamAnthropic(messages, onChunk, onError) {
|
||||
async function streamGoogle(messages, onChunk, onError) {
|
||||
const model = process.env.GOOGLE_MODEL || "gemini-2.0-flash";
|
||||
const apiKey = process.env.GOOGLE_API_KEY;
|
||||
const url = `${GOOGLE_BASE_URL}${apiKey}/chat/models/${model}:streamGenerateContent?key=${apiKey}`;
|
||||
// Safe URL construction to prevent path break (CVE-2026-004)
|
||||
const url = new URL(`chat/models/${model}:streamGenerateContent`, GOOGLE_BASE_URL);
|
||||
url.searchParams.set('key', apiKey);
|
||||
|
||||
const systemMsg = messages.find((m) => m.role === "system");
|
||||
const userMsgs = messages.filter((m) => m.role !== "system");
|
||||
@@ -284,10 +293,12 @@ export async function getChatResponse(chatMessages, grade, subject, chapter) {
|
||||
}
|
||||
case "openai":
|
||||
default: {
|
||||
const isOpenRouter = (process.env.OPENAI_BASE_URL || "").includes("openrouter.ai");
|
||||
const res = await openai.chat.completions.create({
|
||||
model: process.env.OPENAI_MODEL || "gpt-4o",
|
||||
messages,
|
||||
max_tokens: 2048,
|
||||
...(isOpenRouter ? { reasoning: { enabled: false } } : {}),
|
||||
});
|
||||
return res.choices?.[0]?.message?.content || "No response";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const SUPABASE_URL = process.env.SUPABASE_URL;
|
||||
const SUPABASE_SERVICE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
const SUPABASE_PUBLISHABLE_KEY = process.env.SUPABASE_PUBLISHABLE_KEY;
|
||||
|
||||
const supabaseAdmin = createClient(SUPABASE_URL, SUPABASE_SERVICE_KEY);
|
||||
|
||||
// Create a user-scoped client with auth token
|
||||
function createUserClient(token) {
|
||||
return createClient(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY, {
|
||||
global: {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function createSession(userId, grade, subject, chapter, token) {
|
||||
const supabase = token ? createUserClient(token) : supabaseAdmin;
|
||||
const { data, error } = await supabase
|
||||
.from('sessions')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
grade: grade || 'General',
|
||||
subject: subject || 'General',
|
||||
chapter: chapter || 'General',
|
||||
preview: 'New Chat',
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) throw new Error(`Failed to create session: ${error.message}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getSession(sessionId, token) {
|
||||
const supabase = token ? createUserClient(token) : supabaseAdmin;
|
||||
const { data, error } = await supabase
|
||||
.from('sessions')
|
||||
.select('*')
|
||||
.eq('id', sessionId)
|
||||
.single();
|
||||
|
||||
if (error && error.code === 'PGRST116') return null;
|
||||
if (error) throw new Error(`Failed to get session: ${error.message}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function listSessions(userId, token) {
|
||||
const supabase = token ? createUserClient(token) : supabaseAdmin;
|
||||
const { data, error } = await supabase
|
||||
.from('sessions')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.order('updated_at', { ascending: false });
|
||||
|
||||
if (error) throw new Error(`Failed to list sessions: ${error.message}`);
|
||||
return data || [];
|
||||
}
|
||||
|
||||
export async function getMessages(sessionId, token) {
|
||||
const supabase = token ? createUserClient(token) : supabaseAdmin;
|
||||
const { data, error } = await supabase
|
||||
.from('messages')
|
||||
.select('*')
|
||||
.eq('session_id', sessionId)
|
||||
.order('created_at', { ascending: true });
|
||||
|
||||
if (error) throw new Error(`Failed to get messages: ${error.message}`);
|
||||
return data || [];
|
||||
}
|
||||
|
||||
export async function addMessage(sessionId, role, text, token) {
|
||||
const supabase = token ? createUserClient(token) : supabaseAdmin;
|
||||
const { data: messageData, error: msgError } = await supabase
|
||||
.from('messages')
|
||||
.insert({ session_id: sessionId, role, text })
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (msgError) throw new Error(`Failed to add message: ${msgError.message}`);
|
||||
|
||||
const { error: updateError } = await supabase
|
||||
.from('sessions')
|
||||
.update({
|
||||
updated_at: new Date().toISOString(),
|
||||
preview: role === 'user' ? text.substring(0, 60) + (text.length > 60 ? '...' : '') : undefined,
|
||||
})
|
||||
.eq('id', sessionId);
|
||||
|
||||
if (updateError) console.error('Failed to update session:', updateError);
|
||||
|
||||
return messageData;
|
||||
}
|
||||
|
||||
export async function deleteSession(sessionId, token) {
|
||||
const supabase = token ? createUserClient(token) : supabaseAdmin;
|
||||
const { error } = await supabase
|
||||
.from('sessions')
|
||||
.delete()
|
||||
.eq('id', sessionId);
|
||||
|
||||
if (error) throw new Error(`Failed to delete session: ${error.message}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function clearMessages(sessionId, token) {
|
||||
const supabase = token ? createUserClient(token) : supabaseAdmin;
|
||||
const { error } = await supabase
|
||||
.from('messages')
|
||||
.delete()
|
||||
.eq('session_id', sessionId);
|
||||
|
||||
if (error) throw new Error(`Failed to clear messages: ${error.message}`);
|
||||
return true;
|
||||
}
|
||||
@@ -1,23 +1,34 @@
|
||||
/**
|
||||
* In-memory session store.
|
||||
* Each session holds a chatId, metadata (grade/subject/chapter),
|
||||
* Each session holds a chatId, userId, metadata (grade/subject/chapter),
|
||||
* and a list of messages. Swappable for a DB backend later.
|
||||
*/
|
||||
|
||||
const sessions = new Map();
|
||||
const sessions = new Map(); // chatId -> session
|
||||
const messages = new Map(); // chatId -> [Message]
|
||||
const userSessions = new Map(); // userId -> [chatId]
|
||||
|
||||
function generateId() {
|
||||
return `chat_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a user has a sessions list entry
|
||||
*/
|
||||
function ensureUserSessions(userId) {
|
||||
if (!userSessions.has(userId)) {
|
||||
userSessions.set(userId, []);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new chat session
|
||||
*/
|
||||
export function createSession(grade, subject, chapter) {
|
||||
export function createSession(userId, grade, subject, chapter) {
|
||||
const chatId = generateId();
|
||||
const session = {
|
||||
id: chatId,
|
||||
userId,
|
||||
grade: grade || "General",
|
||||
subject: subject || "General",
|
||||
chapter: chapter || "General",
|
||||
@@ -29,6 +40,8 @@ export function createSession(grade, subject, chapter) {
|
||||
|
||||
sessions.set(chatId, session);
|
||||
messages.set(chatId, []);
|
||||
ensureUserSessions(userId);
|
||||
userSessions.get(userId).push(chatId);
|
||||
|
||||
return session;
|
||||
}
|
||||
@@ -41,10 +54,13 @@ export function getSession(chatId) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all sessions (most recent first)
|
||||
* Get all sessions, optionally filtered by userId
|
||||
*/
|
||||
export function listSessions() {
|
||||
const all = Array.from(sessions.values());
|
||||
export function listSessions(userId = null) {
|
||||
let all = Array.from(sessions.values());
|
||||
if (userId) {
|
||||
all = all.filter((s) => s.userId === userId);
|
||||
}
|
||||
return all.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
|
||||
}
|
||||
|
||||
@@ -83,8 +99,22 @@ export function addMessage(chatId, message) {
|
||||
* Delete a session and its messages
|
||||
*/
|
||||
export function deleteSession(chatId) {
|
||||
const session = sessions.get(chatId);
|
||||
const deleted = sessions.delete(chatId);
|
||||
messages.delete(chatId);
|
||||
if (deleted) {
|
||||
messages.delete(chatId);
|
||||
// Remove from user's session list
|
||||
if (session && session.userId) {
|
||||
const userId = session.userId;
|
||||
const userSessList = userSessions.get(userId);
|
||||
if (userSessList) {
|
||||
userSessList.splice(userSessList.indexOf(chatId), 1);
|
||||
if (userSessList.length === 0) {
|
||||
userSessions.delete(userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { validateChatInput, isOwnedSession } from "../src/routes/chatValidation.js";
|
||||
|
||||
test("accepts a valid chat request", () => {
|
||||
assert.deepEqual(validateChatInput({ text: "hello", grade: "Grade 10", subject: "math" }), { ok: true });
|
||||
});
|
||||
|
||||
test("rejects invalid metadata and oversized messages", () => {
|
||||
assert.equal(validateChatInput({ text: "hello", grade: "invalid" }).error, "Invalid grade");
|
||||
assert.equal(validateChatInput({ text: "hello", subject: "invalid" }).error, "Invalid subject");
|
||||
assert.equal(validateChatInput({ text: "" }).error, "Message text is required");
|
||||
assert.equal(validateChatInput({ text: "x".repeat(10001) }).error, "Message too long (max 10000 characters)");
|
||||
});
|
||||
|
||||
test("only the owning user can access a session", () => {
|
||||
assert.equal(isOwnedSession({ user_id: "user-a" }, "user-a"), true);
|
||||
assert.equal(isOwnedSession({ user_id: "user-a" }, "user-b"), false);
|
||||
assert.equal(isOwnedSession(null, "user-a"), false);
|
||||
});
|
||||
Reference in New Issue
Block a user