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:
2026-08-19 09:41:40 -04:00
parent 93a35a7343
commit ff9da4d324
43 changed files with 4174 additions and 249 deletions
+3 -2
View File
@@ -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
+215
View File
@@ -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 -2
View File
@@ -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"
}
}
+47
View File
@@ -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
View File
@@ -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) => {
+9
View File
@@ -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());
}
+26
View File
@@ -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,
});
+103
View File
@@ -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();
}
}
+178
View File
@@ -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
View File
@@ -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;
+19
View File
@@ -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);
}
+69 -27
View File
@@ -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;
+13 -2
View File
@@ -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";
}
+121
View File
@@ -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;
}
+37 -7
View File
@@ -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;
}
+20
View File
@@ -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);
});