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:
+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;
|
||||
|
||||
Reference in New Issue
Block a user