# padhle — Implementation Plan ## Rate Limiting + Supabase Integration + Session Bug Fix **Date:** 2026-08-19 **Total Estimated Time:** ~3 hours **Status:** Ready to implement --- ## Issues to Address ### 🔴 Critical: Session Data Leak (User A ↔ User B) **Symptoms:** - User A logs in → chats with AI → logs out - User B logs in → sidebar empty (correct) - BUT User B sees User A's conversation on main screen ❌ **Root Cause:** - Frontend `activeChat` state not cleared on sign-out - In-memory backend store allows session ID guessing - Stale `messages` state renders after User A logs out --- ### 🟡 Security: No Rate Limiting - Auth endpoints (`/signup`, `/signin`) vulnerable to brute-force - No per-IP throttling --- ### 🟠 Architecture: In-Memory Sessions - Sessions lost on server restart - Not tied to database — only scoped via cookies - Need Supabase Postgres for persistence --- ## Implementation Phases ### **PHASE 1: Rate Limiting by IP (30 min)** **Goal:** Prevent brute-force attacks on auth endpoints #### 1.1 Install dependency ```bash cd backend npm install express-rate-limit ``` #### 1.2 Create middleware file **File:** `backend/src/middleware/rateLimiter.js` (NEW) ```js import rateLimit from 'express-rate-limit'; 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', }); 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, }); 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, }); ``` #### 1.3 Apply to auth routes **File:** `backend/src/routes/auth.js` (MODIFY top of file) Add import: ```js import { authLimiter } from '../middleware/rateLimiter.js'; ``` Then apply to each route: ```js router.post('/signup', authLimiter, async (req, res) => { ... }); router.post('/signin', authLimiter, async (req, res) => { ... }); router.post('/signout', authLimiter, async (req, res) => { ... }); ``` #### 1.4 Apply to other routes **File:** `backend/src/index.js` (MODIFY) Add imports: ```js import { chatLimiter, sessionsLimiter } from './middleware/rateLimiter.js'; ``` Update middleware order: ```js app.use('/api/chat', chatLimiter, supabaseAuth, chatRoutes); app.use('/api/sessions', sessionsLimiter, supabaseAuth, sessionRoutes); ``` #### 1.5 Test ```bash cd backend && npm start # Test rate limiting (6th request should fail) for i in {1..7}; do curl -X POST http://localhost:3001/api/auth/signup \ -H 'Content-Type: application/json' \ -d "{\"email\": \"test$i@example.com\", \"password\": \"test\"}" echo "" done # Expect 429 on 6th attempt ``` --- ### **PHASE 2: Supabase Database Integration (90 min)** **Goal:** Move sessions & messages from in-memory to Supabase Postgres with RLS #### 2.1 Create database migration **File:** `backend/supabase/migrations/{timestamp}_create_sessions_messages.sql` (NEW) ```sql -- sessions table CREATE TABLE public.sessions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, grade TEXT, subject TEXT, chapter TEXT, preview TEXT, created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now() ); -- messages table CREATE TABLE public.messages ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), session_id UUID NOT NULL REFERENCES public.sessions(id) ON DELETE CASCADE, role TEXT NOT NULL CHECK (role IN ('user', 'assistant')), text TEXT NOT NULL, created_at TIMESTAMPTZ DEFAULT now() ); -- profiles table (for future use) CREATE TABLE public.profiles ( id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, email TEXT NOT NULL, display_name TEXT, avatar_url TEXT, created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now() ); -- Indexes CREATE INDEX idx_sessions_user_id ON public.sessions(user_id); CREATE INDEX idx_messages_session_id ON public.messages(session_id); -- Enable RLS ALTER TABLE public.sessions ENABLE ROW LEVEL SECURITY; ALTER TABLE public.messages ENABLE ROW LEVEL SECURITY; ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY; -- RLS: sessions CREATE POLICY "Users view own sessions" ON public.sessions FOR SELECT USING (auth.uid() = user_id); CREATE POLICY "Users create sessions" ON public.sessions FOR INSERT WITH CHECK (auth.uid() = user_id); CREATE POLICY "Users update own sessions" ON public.sessions FOR UPDATE USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id); CREATE POLICY "Users delete own sessions" ON public.sessions FOR DELETE USING (auth.uid() = user_id); -- RLS: messages CREATE POLICY "Users view own messages" ON public.messages FOR SELECT USING (session_id IN (SELECT id FROM public.sessions WHERE user_id = auth.uid())); CREATE POLICY "Users insert own messages" ON public.messages FOR INSERT WITH CHECK (session_id IN (SELECT id FROM public.sessions WHERE user_id = auth.uid())); CREATE POLICY "Users delete own messages" ON public.messages FOR DELETE USING (session_id IN (SELECT id FROM public.sessions WHERE user_id = auth.uid())); -- RLS: profiles CREATE POLICY "Users view own profile" ON public.profiles FOR SELECT USING (auth.uid() = id); CREATE POLICY "Users update own profile" ON public.profiles FOR UPDATE USING (auth.uid() = id) WITH CHECK (auth.uid() = id); ``` #### 2.2 Apply migration ```bash cd /path/to/padhle npx supabase db push ``` Verify in Supabase Studio (http://localhost:54323): - Tables exist: `sessions`, `messages`, `profiles` - RLS enabled on all three - Policies listed under each table #### 2.3 Create database service **File:** `backend/src/services/db.js` (NEW) ```js 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 = createClient(SUPABASE_URL, SUPABASE_SERVICE_KEY); export async function createSession(userId, grade, subject, chapter) { 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) { 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) { 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) { 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) { 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) { 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) { const { error } = await supabase .from('messages') .delete() .eq('session_id', sessionId); if (error) throw new Error(`Failed to clear messages: ${error.message}`); return true; } ``` #### 2.4 Install SDK ```bash cd backend npm install @supabase/supabase-js ``` #### 2.5 Update routes to use DB service **File:** `backend/src/routes/chat.js` (MODIFY) Replace top imports: ```js // OLD import { createSession, addMessage, getMessages, getSession } from "../stores/sessionStore.js"; // NEW import { createSession, addMessage, getMessages, getSession } from "../services/db.js"; ``` Update function calls (add `await`): ```js // OLD: session = createSession(userId, grade, subject, chapter); // NEW: session = await createSession(userId, grade, subject, chapter); // OLD: addMessage(currentChatId, userMsg); // NEW: await addMessage(currentChatId, userMsg.role, userMsg.text); // OLD: getMessages(currentChatId) // NEW: const messages = await getMessages(currentChatId); // OLD: addMessage(currentChatId, assistantMsg); // NEW: await addMessage(currentChatId, assistantMsg.role, assistantMsg.text); ``` Wrap in try-catch: ```js try { session = await createSession(...); // ... rest of logic } catch (err) { return res.status(500).json({ error: 'Database error' }); } ``` **File:** `backend/src/routes/sessions.js` (MODIFY) Same replacements as chat.js: ```js import { listSessions, getSession, deleteSession, clearMessages, createSession } from "../services/db.js"; router.get('/', async (req, res) => { try { const sessions = await listSessions(req.user.uid); res.json(sessions); } catch (err) { res.status(500).json({ error: 'Failed to load sessions' }); } }); // ... same for POST, GET/:id, DELETE/:id, PATCH/:id/clear ``` #### 2.6 Update .env **File:** `backend/.env` (ADD) ``` SUPABASE_SERVICE_ROLE_KEY=sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz ``` (Get key from `npx supabase status`) #### 2.7 Test integration ```bash cd backend && npm start # Create a session curl -X POST http://localhost:3001/api/sessions \ -H 'Content-Type: application/json' \ -H 'Cookie: padhle.token=' \ -d '{"grade": "Grade 10", "subject": "math", "chapter": "Chapter 1"}' # Check Supabase Studio: http://localhost:54323 # → Tables → sessions → should see new row ``` --- ### **PHASE 3: Fix Session Leak Bug (45 min)** **Goal:** Ensure User A's data not visible to User B #### 3.1 Fix frontend state cleanup **File:** `frontend/src/App.jsx` (MODIFY) Find `useEffect([isAuthenticated])` around line 85. Replace entire block: ```js useEffect(() => { if (!hasMountedRef.current) { hasMountedRef.current = true; } else if (isAuthenticated) { // User just signed in after sign-out setMessages([]); setActiveChat(null); // CRITICAL: clear before loading setActiveGrade(DEFAULTS.grade); setActiveSubject(DEFAULTS.subject); setActiveChapter(DEFAULTS.chapter); setIsTyping(false); setSelectorStep('grade'); loadSessions(); // Load fresh sessions for current user } else { // User just signed out setMessages([]); setActiveChat(null); setActiveGrade(DEFAULTS.grade); setActiveSubject(DEFAULTS.subject); setActiveChapter(DEFAULTS.chapter); setIsTyping(false); setSelectorStep('grade'); setSessions([]); } }, [isAuthenticated]); ``` #### 3.2 Add safety check in ChatHistory **File:** `frontend/src/components/chat-history/ChatHistory.jsx` (MODIFY at top of component) Add safety check before rendering: ```js function ChatHistory({ messages, sessions, activeChat, selectorStep, setMessages, setActiveChat, ...props }) { // Safety: verify activeChat session still exists const sessionExists = activeChat && sessions.some(s => s.id === activeChat); if (selectorStep !== 'chat') { return ; } if (!sessionExists && messages.length > 0) { // Stale state detected — clear setMessages([]); setActiveChat(null); return ; } if (messages.length === 0) { return ; } return ; } ``` #### 3.3 Verify RLS on backend **File:** `backend/src/routes/sessions.js` (VERIFY ownership check) ```js router.get('/:id', async (req, res) => { try { const userId = req.user.uid; const session = await getSession(req.params.id); // Defense-in-depth: explicit ownership check if (!session || session.user_id !== userId) { return res.status(404).json({ error: 'Session not found' }); } res.json(session); } catch (err) { res.status(500).json({ error: 'Failed to load session' }); } }); ``` #### 3.4 E2E Test ``` 1. Open Window 1 (Firefox) 2. Sign in as User A 3. Send message → create session 4. Verify session in sidebar ✓ 5. Sign out 6. Open Window 2 (Chrome) or incognito tab 7. Sign in as User B 8. Verify: - Sidebar empty ✓ - Main screen shows welcome (NOT User A's chat) ✓ 9. Send a message → create new session 10. Sign out User B 11. In Window 1: Sign back in as User A 12. Verify User A sees original session ✓ ``` --- ### **PHASE 4: Error Handling & Validation (30 min)** #### 4.1 Sanitize AI provider errors **File:** `backend/src/services/ai.js` (MODIFY error handlers) In `streamAnthropic`: ```js if (!response.ok) { const errText = await response.text(); console.error('Anthropic error:', errText); // Log server-side only onError('AI service encountered an error. Please try again.'); return ''; } ``` In `streamGoogle`: ```js if (!response.ok) { const errText = await response.text(); console.error('Google error:', errText); // Log server-side only onError('AI service encountered an error. Please try again.'); return ''; } ``` #### 4.2 Fix Anthropic non-streaming endpoint **File:** `backend/src/services/ai.js` (MODIFY line ~138) ```js // OLD const res = await fetch(ANTHROPIC_BASE_URL, { // NEW const res = await fetch(`${ANTHROPIC_BASE_URL}/messages`, { ``` #### 4.3 Validate metadata **File:** `backend/src/routes/chat.js` (ADD at top) ```js const VALID_GRADES = ['Grade 6', 'Grade 7', 'Grade 8', 'Grade 9', 'Grade 10', 'Grade 11', 'Grade 12', 'General', 'Choose standard']; const VALID_SUBJECTS = ['math', 'science', 'history', 'language', 'english', 'geography', 'General', 'choose-subject']; ``` Then in POST handler: ```js if (grade && !VALID_GRADES.includes(grade)) { return res.status(400).json({ error: 'Invalid grade' }); } if (subject && !VALID_SUBJECTS.includes(subject)) { return res.status(400).json({ error: 'Invalid subject' }); } ``` #### 4.4 Test error paths ```bash # Invalid grade curl -X POST http://localhost:3001/api/chat \ -H 'Cookie: padhle.token=' \ -H 'Content-Type: application/json' \ -d '{"text": "hello", "grade": "invalid"}' # Expect 400 # Message too long curl -X POST http://localhost:3001/api/chat \ -H 'Cookie: padhle.token=' \ -H 'Content-Type: application/json' \ -d '{"text": "'$(printf 'x%.0s' {1..10001})'}' # Expect 400 ``` --- ## Summary Table | Phase | Task | Time | Dependencies | |-------|------|------|--------------| | 1.1 | Install `express-rate-limit` | 2 min | — | | 1.2 | Create `rateLimiter.js` | 5 min | — | | 1.3 | Apply to auth.js | 5 min | 1.2 | | 1.4 | Apply to index.js | 5 min | 1.3 | | 1.5 | Test rate limiting | 13 min | 1.1–1.4 | | **Phase 1 Total** | **Rate Limiting** | **30 min** | — | | 2.1 | Create SQL migration | 10 min | — | | 2.2 | Apply migration | 5 min | 2.1 | | 2.3 | Create `db.js` | 20 min | 2.2 | | 2.4 | Install SDK | 3 min | — | | 2.5 | Update chat.js & sessions.js | 20 min | 2.3, 2.4 | | 2.6 | Update .env | 2 min | — | | 2.7 | Test integration | 15 min | 2.1–2.6 | | **Phase 2 Total** | **DB Integration** | **75 min** | Phase 1 (optional) | | 3.1 | Fix App.jsx state cleanup | 10 min | — | | 3.2 | Add ChatHistory safety check | 10 min | 3.1 | | 3.3 | Verify RLS in sessions.js | 5 min | 2.7 | | 3.4 | E2E test | 20 min | 3.1–3.3 | | **Phase 3 Total** | **Fix Session Leak** | **45 min** | Phase 2 | | 4.1 | Sanitize AI errors | 5 min | — | | 4.2 | Fix Anthropic endpoint | 2 min | — | | 4.3 | Validate metadata | 10 min | — | | 4.4 | Test error paths | 13 min | 4.1–4.3 | | **Phase 4 Total** | **Error Handling** | **30 min** | — | | | | | | | **GRAND TOTAL** | **All Phases** | **~180 min (3 hrs)** | | --- ## Verification Checklist Before marking complete, verify: - [ ] Phase 1: Rate limiting blocks 6th auth attempt - [ ] Phase 2: Sessions stored in Supabase (check Studio) - [ ] Phase 2: User A's session NOT visible to User B via API - [ ] Phase 3: Frontend clears state on sign-out - [ ] Phase 3: User B sees welcome screen (not User A's chat) after sign-in - [ ] Phase 3: E2E test passes (User A → chat → signout → User B → fresh state) - [ ] Phase 4: Invalid grade returns 400 - [ ] Phase 4: AI errors return generic message to client - [ ] Phase 4: Anthropic non-streaming calls work - [ ] All tests pass locally - [ ] No console errors in browser - [ ] No console errors in backend --- ## Next Steps After Implementation 1. **Staging deployment** — Test on staging server 2. **Production safety** — Ensure `.env` secrets not exposed 3. **Monitoring** — Log rate limit hits, DB errors 4. **Performance** — Monitor Supabase query times 5. **Backup** — Set up Supabase backup schedule 6. **Documentation** — Update API docs with RLS details --- Ready to start? Let me know which phase first, or proceed sequentially.