18 KiB
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
activeChatstate not cleared on sign-out - In-memory backend store allows session ID guessing
- Stale
messagesstate 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
cd backend
npm install express-rate-limit
1.2 Create middleware file
File: backend/src/middleware/rateLimiter.js (NEW)
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:
import { authLimiter } from '../middleware/rateLimiter.js';
Then apply to each route:
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:
import { chatLimiter, sessionsLimiter } from './middleware/rateLimiter.js';
Update middleware order:
app.use('/api/chat', chatLimiter, supabaseAuth, chatRoutes);
app.use('/api/sessions', sessionsLimiter, supabaseAuth, sessionRoutes);
1.5 Test
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)
-- 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
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)
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
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:
// 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):
// 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:
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:
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=<rotated-local-dev-service-role-key>
(Get key from npx supabase status — never commit the real value; see docs/security-hardening-plan.md Phase 1)
2.7 Test integration
cd backend && npm start
# Create a session
curl -X POST http://localhost:3001/api/sessions \
-H 'Content-Type: application/json' \
-H 'Cookie: padhle.token=<valid_jwt>' \
-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:
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:
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 <SelectorFlow {...props} />;
}
if (!sessionExists && messages.length > 0) {
// Stale state detected — clear
setMessages([]);
setActiveChat(null);
return <WelcomeScreen />;
}
if (messages.length === 0) {
return <WelcomeScreen />;
}
return <MessageList messages={messages} />;
}
3.3 Verify RLS on backend
File: backend/src/routes/sessions.js (VERIFY ownership check)
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:
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:
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)
// 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)
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:
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
# Invalid grade
curl -X POST http://localhost:3001/api/chat \
-H 'Cookie: padhle.token=<jwt>' \
-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=<jwt>' \
-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
- Staging deployment — Test on staging server
- Production safety — Ensure
.envsecrets not exposed - Monitoring — Log rate limit hits, DB errors
- Performance — Monitor Supabase query times
- Backup — Set up Supabase backup schedule
- Documentation — Update API docs with RLS details
Ready to start? Let me know which phase first, or proceed sequentially.