Files
padhle/IMPLEMENTATION_PLAN.md
2026-09-15 04:08:55 -04:00

662 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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=<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
```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=<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:
```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 <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)
```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=<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.11.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.12.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.13.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.14.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.