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
+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;