Files
padhle/IMPLEMENTATION_PLAN.md
T
hahachait ff9da4d324 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
2026-08-19 09:41:40 -04:00

18 KiB
Raw Blame History

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

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=sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz

(Get key from npx supabase status)

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.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.