diff --git a/.gitignore b/.gitignore
index a547bf3..9275b8b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,6 +12,14 @@ dist
dist-ssr
*.local
+# Supabase self-hosted
+docker-compose.yml
+.env
+.env.*
+!.env.example
+volumes/
+logs/
+
# Editor directories and files
.vscode/*
!.vscode/extensions.json
diff --git a/ANALYSIS.md b/ANALYSIS.md
new file mode 100644
index 0000000..f7ada86
--- /dev/null
+++ b/ANALYSIS.md
@@ -0,0 +1,668 @@
+# padhle — Codebase Analysis & Feature Tracker
+
+## File Map
+
+| File | Purpose |
+|---|---|
+| `frontend/src/App.jsx` | Main orchestrator — owns all state, SSE chat logic, session CRUD, auth gating |
+| `frontend/src/styles/global.css` | Global CSS variables (colors, fonts, spacing, radius, shadows) |
+| `frontend/src/main.jsx` | React entry point — wraps App in `AuthProvider` |
+| `frontend/src/lib/auth/SupabaseAuth.jsx` | Auth context — `signIn`, `signUp`, `signOut`, `getToken` (all via backend) |
+| `frontend/src/lib/auth/backendAuth.js` | Backend auth client — `signup`, `signin`, `signout`, `me`, `check` |
+| `frontend/src/lib/auth/SignIn.jsx` | Sign-in/Sign-up modal form |
+| `frontend/src/lib/auth/SignIn.css` | Modal styles |
+| `frontend/src/components/selector-flow/SelectorFlow.jsx` | Generic step-based selector — grade, subject, chapter (grid layout, conditional icons) |
+| `frontend/src/components/selector-flow/SelectorFlow.css` | Grid layout for grade options |
+| `frontend/src/components/top-nav/TopNav.jsx` | Header with grade/subject/chapter dropdown pills + search/notifications/user avatar |
+| `frontend/src/components/top-nav/TopNav.css` | Pill buttons, dropdown menus, avatar, sign-out styles |
+| `frontend/src/components/sidebar/Sidebar.jsx` | Brand, New Chat button, subjects list, chat history list, user info, sign-out, upgrade card |
+| `frontend/src/components/sidebar/Sidebar.css` | Sidebar layout, hover indicators, chat item delete, upgrade card, user info |
+| `frontend/src/components/chat-history/ChatHistory.jsx` | Welcome screen with bento suggestions OR message list OR selector flow |
+| `frontend/src/components/chat-history/ChatHistory.css` | Welcome header, bento grid, info panel, message list |
+| `frontend/src/components/message/Message.jsx` | Single message bubble (user or assistant), action buttons, typing dots |
+| `frontend/src/components/message/Message.css` | Bubble styling, avatar, actions, typing animation |
+| `frontend/src/components/chat-input/ChatInput.jsx` | Textarea with auto-expand, mic, send button, attachment |
+| `frontend/src/components/chat-input/ChatInput.css` | Input bar styling, send button states, disclaimer |
+| `backend/src/index.js` | Express app setup, middleware, route registration |
+| `backend/src/middleware/cookies.js` | Cookie parser middleware (cookie-parser) |
+| `backend/src/middleware/supabaseAuth.js` | JWT verification middleware — reads from `padhle.token` httpOnly cookie |
+| `backend/src/routes/auth.js` | POST /api/auth/signup, /api/auth/signin, /api/auth/signout; GET /api/auth/me |
+| `backend/src/routes/chat.js` | POST /api/chat (stream), GET /api/chat/sessions, GET /api/chat/:id (load session+messages); validates metadata and enforces ownership |
+| `backend/src/routes/sessions.js` | GET/POST/DELETE/PATCH /api/sessions; explicit ownership checks |
+| `backend/src/services/db.js` | Supabase database service for profiles, sessions, and messages |
+| `backend/src/routes/chatValidation.js` | Pure chat input validation and session ownership helpers |
+| `backend/src/stores/sessionStore.js` | Legacy in-memory Map-based session + message store; no longer used by chat/session routes |
+| `frontend/src/appState.js` | Central default/reset state for auth transitions |
+| `backend/src/services/ai.js` | AI provider abstraction (OpenAI/Anthropic/Google), streaming, system prompt builder |
+
+---
+
+## Architecture
+
+```
+Frontend (React + Vite) ──SSE──▶ Backend (Express) ──API──▶ AI Provider
+ │ │
+ ├─ State: activeGrade, └─ Supabase Postgres service
+ activeSubject, └─ Explicit req.user.uid ownership checks
+ activeChapter,
+ messages,
+ selectorStep (grade → subject → chapter → chat)
+ │
+ └─ Auth: httpOnly cookies (padhle.token, padhle.user)
+ └─ Browser API requests use credentials: 'include'
+```
+
+---
+
+## Authentication — How It Works
+
+All authentication is handled server-side via httpOnly cookies. The frontend never sees raw tokens.
+
+### Flow
+
+```
+1. User enters email + password → Frontend POST /api/auth/signin
+2. Backend calls Supabase Auth API → receives JWT
+3. Backend sets httpOnly cookies:
+ - padhle.token = JWT (for API verification)
+ - padhle.user = JSON({ uid, email }) (safe user metadata)
+4. Frontend never sees the JWT — browser stores cookies automatically
+5. All API calls include cookies via credentials: 'include'
+6. Backend middleware reads padhle.token, verifies via /auth/v1/user
+7. If valid, req.user = { uid, email } is attached to request
+```
+
+### Auth Endpoints
+
+| Route | Method | Purpose | Auth Required |
+|---|---|---|---|
+| `/api/auth/signup` | POST | Create account via Supabase Auth | No |
+| `/api/auth/signin` | POST | Sign in via Supabase Auth | No |
+| `/api/auth/signout` | POST | Revoke session + clear cookies | Yes |
+| `/api/auth/me` | GET | Get current user from cookie | Yes |
+
+### Protected Routes
+
+| Route | Middleware | Purpose |
+|---|---|---|
+| `/api/chat` | supabaseAuth | Send message, load conversation |
+| `/api/sessions` | supabaseAuth | CRUD chat sessions |
+
+### Security
+
+- **httpOnly cookies**: JWT is never accessible to JavaScript
+- **sameSite: lax**: CSRF protection
+- **secure flag**: Enabled in production (HTTPS)
+- **JWT verification**: Each request calls Supabase Auth server to validate
+- **No localStorage tokens**: Sensitive data never stored in browser
+- **Session revocation**: Signout calls Supabase `/auth/v1/logout` server-side
+
+---
+
+## Grade Selector — Current State
+
+### Where it lives
+
+| Component | File | Role |
+|---|---|---|
+| `SelectorFlow` | `frontend/src/components/selector-flow/SelectorFlow.jsx` | Generic step-based selector — grade, subject, chapter (grid layout) |
+| `TopNav` grade pill | `frontend/src/components/top-nav/TopNav.jsx` (lines ~46-75) | Always-visible header dropdown |
+| State owner | `frontend/src/App.jsx` | `selectorStep` + `activeGrade/Subject/Chapter` state |
+| `ChatHistory` | `frontend/src/components/chat-history/ChatHistory.jsx` | Renders SelectorFlow when `selectorStep !== "chat"` |
+
+### Sequential selector flow
+
+```
+New Chat → selectorStep = "grade"
+ → Pick Grade → selectorStep = "subject" → fade-in
+ → Pick Subject → selectorStep = "chapter" → fade-in (subject-aware chapters)
+ → Pick Chapter → selectorStep = "chat" → welcome screen / messages
+```
+
+### State values
+
+| State | Type | Default | Stored in DB |
+|---|---|---|---|
+| `activeGrade` | label | `"Choose standard"` | `"Grade 10"` |
+| `activeSubject` | ID | `"choose-subject"` | `"math"` |
+| `activeChapter` | label | `"choose-chapter"` | `"Chapter 1: Rational Numbers"` |
+| `selectorStep` | enum | `"grade"` | — |
+
+| Value | Used in |
+|---|---|
+| `"Choose standard"` | Default for `activeGrade` |
+| `Grade 6` .. `Grade 12` | SelectorFlow + TopNav grade options |
+| `"math"` .. `"geography"` | `activeSubject` IDs (subjectItems) |
+| `"choose-subject"` | Default/placeholder for `activeSubject` |
+| `"choose-chapter"` | Default/placeholder for `activeChapter` |
+
+### Data flow
+
+```
+User clicks grade in SelectorFlow
+ → handleGradeSelect(item) → setActiveGrade(item.label) → pill shows label
+
+User clicks subject in SelectorFlow
+ → handleSubjectSelect(item) → setActiveSubject(item.id) → "math"
+ → getSubjectLabel("math") → "Mathematics" (for pill/placeholder display)
+ → handleChatSelect restores: data.session.subject = "math" → matches subject.id
+
+User clicks subject in Sidebar
+ → onSubjectChange(id) → setActiveSubject(id) → resets selectorStep to "subject"
+
+User clicks subject in TopNav dropdown
+ → selectOption("subject", subject.id) → onSubjectChange(id)
+ → setActiveSubject(id) → subject-aware chapter dropdown updates
+
+User clicks grade/chapter in TopNav
+ → selectOption("grade", grade.label) / selectOption("chapter", chapter.label)
+ → directly updates state
+```
+
+### Subject label resolution (central pattern)
+
+```
+activeSubject (stores ID) = "math"
+ → getSubjectLabel("math") → "Mathematics"
+ → TopNav pill: resolvedSubjectLabel
+ → ChatInput placeholder: `${getSubjectLabel(activeSubject)}`
+ → SelectorFlow subtitle: `Subject – ${getSubjectLabel(activeSubject)}`
+ → TopNav dropdown: activeSubject === subject.id → highlights Mathematics
+ → Sidebar: activeSubject === subject.id → highlights Mathematics
+```
+
+### Chapter label resolution
+
+```
+activeChapter (stores ID/label) = "choose-chapter" → getChapterLabel("choose-chapter") → "Choose Chapter"
+activeChapter (stored label) = "Chapter 1: Rational Numbers" → getChapterLabel(...) → "Chapter 1: Rational Numbers"
+ → TopNav pill: resolvedChapterLabel
+ → ChatInput placeholder: `${getChapterLabel(activeChapter)}`
+```
+
+### TopNav chapter dropdown (subject-aware)
+
+```
+chapterData[activeSubject] → chapterOptions array
+ → ["Choose Chapter", ...chaptersFromSubject]
+ → Changes dynamically when activeSubject changes
+ → Previously: static list mixing chapters from all subjects
+```
+
+### How grade is used
+
+- Sent in `POST /api/chat` body → `grade` field (only when not default)
+- Used in AI system prompt via `buildSystemPrompt()` → `"You are teaching students in grade X"`
+- Displayed in chat input placeholder: `"Ask anything about {grade} {subject} – {chapter}..."`
+- Restored when loading a session from `data.session.grade`
+
+### SelectorFlow component
+- Generic: accepts `title`, `subtitle`, `items`, `selectedValue`, `onSelect`, `step`, `compareField` props
+- `compareField` determines which property to compare against `selectedValue` for active highlighting:
+ - `"label"` → `selectedValue === item.label` (used for grades and chapters)
+ - `"id"` → `selectedValue === item.id` (used for subjects)
+- Renders any item type via 3-column grid with fade-in animation
+- Conditional icon rendering: `item.icon && (icon)`
+- Fade-in animation on step change via `key={step}` — CSS `@keyframes selectorFadeIn` with `opacity` + `translateY(16px→0)` over 0.35s
+
+### Selector data
+- **Grades**: 7 options (Grade 6–12), each with icon (Material Symbols)
+- **Subjects**: 6 options (Math, Science, History, Language, English, Geography) — label only, no icons
+- **Chapters**: 6 per subject, subject-aware via `chapterData` object in App.jsx — label only, no icons
+- **Chapter mapping**: `chapterData[subjectId]` → array of chapters (e.g. math → Rational Numbers, Linear Equations, etc.)
+
+### Current features ✅
+
+- Sequential selector flow: grade → subject → chapter → chat (fade-in transitions)
+- Grade selection via SelectorFlow (new-chat grid)
+- Grade selection via TopNav (header pill dropdown)
+- Subject selection in SelectorFlow (label-only, no icons)
+- Chapter selection in SelectorFlow (subject-aware, label-only, no icons)
+- Grade persists across session restore
+- Grade sent to AI backend for context-aware responses
+- 7 grades available (Grade 6–12)
+- Upgrade-to-Pro card hidden via `showUpgrade={false}` prop
+
+---
+
+## Feature Tracker
+
+### Grade Selector
+- **Status**: Working ✅
+- **Verified**: Yes — hover effect confirmed
+- **Last updated**: 2026-01-18
+
+### Hover effect (✅ verified)
+- Subtle lift: `translateY(-2px)`
+- Box-shadow pop via `--shadow-card-hover`
+- Smooth transition on transform and shadow only
+- Press-down feel on `:active` (`translateY(0)`)
+- File: `frontend/src/components/selector-flow/SelectorFlow.css`
+
+### Sequential selector flow (✅ verified)
+- Steps: grade → subject → chapter → chat (via `selectorStep` state)
+- Fade-in animation on each step change: `opacity 0→1` + `translateY(16px→0)` over 0.35s
+- Triggered by `key={step}` on `.selector-flow` — forces React re-mount
+- Subject-aware chapters: `chapterData[subjectId]` returns relevant chapters
+- File: `frontend/src/App.jsx` (selector data + step logic), `frontend/src/components/chat-history/ChatHistory.jsx` (conditional rendering)
+
+### Subject ID/label resolution pattern (✅ verified in browser)
+- `activeSubject` now stores **IDs** (e.g. `"math"`, `"science"`) instead of labels
+- Default is `"choose-subject"` (a reserved placeholder ID)
+- `getSubjectLabel(id)` resolves IDs to display names: `"math"` → `"Mathematics"`
+- Used in: TopNav pill text, ChatInput placeholder, SelectorFlow subtitle, `handleSend` body
+- `handleSubjectSelect` stores `item.id` (not `item.label`)
+- `handleChatSelect` restoration: backend returns `subject: "math"` which matches `subject.id`
+- **TopNav**: uses `subjectItems` prop; `activeSubject === subject.id` comparison works; pill uses `resolvedSubjectLabel`
+- **TopNav chapter dropdown**: uses `chapterData` prop; generates `chapterOptions[activeSubject]` dynamically (subject-aware)
+- **Sidebar**: `activeSubject === subject.id` comparison now works correctly; sidebar subject change resets selector to `subject` step
+- **SelectorFlow**: `compareField="id"` for subjects, `compareField="label"` for grades/chapters
+- **ChatInput placeholder**: uses `getSubjectLabel(activeSubject)` and `getChapterLabel(activeChapter)` for full label resolution
+- Files: `App.jsx`, `TopNav.jsx`, `Sidebar.jsx`, `SelectorFlow.jsx`, `ChatInput.jsx`
+
+### Chapter pill resolution (✅ verified in browser)
+- `activeChapter` stores chapter labels (e.g. `"Chapter 1: Rational Numbers"`) after selection
+- Default is `"choose-chapter"` (placeholder ID)
+- `getChapterLabel(chapterId)` resolves placeholder: `"choose-chapter"` → `"Choose Chapter"`
+- Used in: TopNav pill, ChatInput placeholder
+- Files: `App.jsx`, `TopNav.jsx`
+
+### TopNav subject-aware chapter dropdown (✅ verified in browser)
+- Replaced static 6-item chapters array in TopNav with dynamic `chapterOptions` generated from `chapterData[activeSubject]`
+- Shows subject-specific chapters based on current selection:
+ - Math → Rational Numbers, Linear Equations, Coordinate Geometry, Algebra, Geometry, Trigonometry
+ - Science → Nutrition in Plants, Photosynthesis, Human Physiology, etc.
+ - (all 6 subjects × 6 chapters)
+- Dropdown placeholder highlights correctly when `activeChapter` is `"choose-chapter"`
+- Files: `TopNav.jsx` (chapterOptions generation, chapterData prop), `App.jsx` (chapterData pass-through)
+
+### Upgrade-to-Pro card hidden (✅ verified)
+- `Sidebar` component accepts `showUpgrade` prop (default `true`)
+- `App.jsx` passes `showUpgrade={false}`
+- Card is wrapped in `{showUpgrade && (...)}` conditional
+- Can be re-enabled by setting `showUpgrade={true}`
+- File: `frontend/src/components/sidebar/Sidebar.jsx`, `frontend/src/App.jsx`
+
+---
+
+## Supabase
+
+### Installation
+
+Supabase CLI (local dev via `npx supabase start`).
+- **Project path**: `supabase/` (CLI-managed)
+- **Start**: `npx supabase start`
+- **Stop**: `npx supabase stop`
+- **Status**: `npx supabase status`
+- **Services**: Postgres, GoTrue (Auth), PostgREST (REST), Storage, Supavisor (pooler), Realtime, Edge Runtime, Studio (dashboard), Kong (API gateway), Mailpit (email testing), Analytics (Logflare), Vector (logging)
+
+### Dashboard (Studio)
+
+| Setting | Value |
+|---|---|
+| **URL** | `http://localhost:54323` |
+| **Mailpit** | `http://localhost:54324` |
+
+### API Keys (local dev)
+
+| Key | Value |
+|---|---|
+| **Publishable Key** (client-side) | `sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH` |
+| **Secret Key** (server-side) | `sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz` |
+
+> **Never expose the Secret Key in client code.** Use the publishable key in frontend, secret key in backend.
+> Local dev uses shared default keys — **do not use in production**.
+
+### Database
+
+| Setting | Value |
+|---|---|
+| **Host** | `localhost` |
+| **Port** | `54322` |
+| **Database** | `postgres` |
+| **User** | `postgres` |
+| **Password** | `postgres` |
+
+**Connection string:**
+```bash
+psql 'postgres://postgres:postgres@localhost:54322/postgres'
+```
+
+### API Endpoints (via Kong gateway)
+
+| Service | Endpoint |
+|---|---|
+| **REST** | `http://localhost:54321/rest/v1/` |
+| **Auth** | `http://localhost:54321/auth/v1/` |
+| **Storage** | `http://localhost:54321/storage/v1/` |
+| **Realtime** | `http://localhost:54321/realtime/v1/` |
+| **GraphQL** | `http://localhost:54321/graphql/v1` |
+| **Edge Functions** | `http://localhost:54321/functions/v1/` |
+| **MCP** | `http://localhost:54321/mcp` |
+
+### Supabase MCP
+
+- **Endpoint**: `http://localhost:54321/mcp`
+- **Mode**: Read-only (local dev)
+- **Auth**: Local (no cloud OAuth)
+- **Tools**: 10 (database, debugging, development, docs)
+- **Config**: `~/.pi/agent/supabase.json` (callback port 54326 — default 54324 conflicts with Mailpit)
+- **CLI**: `pi install npm:pi-supabase` → then `supabase_mcp_connect()`
+
+### Database State
+
+- **Custom tables**: `profiles`, `sessions`, and `messages` exist in the connected local Supabase database
+- **Auth tables**: Supabase-managed (`auth.users`, `auth.sessions`, `auth.refresh_tokens`, etc.)
+- **RLS**: Enabled on custom tables and auth tables
+- **Permissions (FIXED)**: API roles lacked table grants, causing PostgreSQL `42501 permission denied`. Fixed via grant to `anon`/`authenticated`/`service_role`; tracked in `supabase/migrations/20260819120000_grant_api_role_table_access.sql`, which also sets `ALTER DEFAULT PRIVILEGES` so future tables inherit the grants.
+- **Profiles (control)**: `profiles` has `role` (default `user`) and `is_banned` (default `false`) columns. A trigger on `auth.users` auto-creates a profile row on any user creation (signup or admin). `authenticated` may update only `display_name`/`avatar_url` — it cannot change `role`/`is_banned`; `service_role` has full control. Migration: `supabase/migrations/20260819130000_profiles_control_and_trigger.sql`.
+- **Signups**: enabled (email verification not yet wired to the app).
+- **User data**: all previous test accounts were deleted (2026-08-19). `auth.users`/`profiles` are empty; no admin user yet. Admin user is added manually in Studio (Auth → Add user, email_confirm on) and then promoted to `role='admin'`.
+- **Verified**: self-signup and admin-created users both auto-create a `profiles` row with `role:'user'`; the role is_banned self-edit is blocked at the column-privilege level.
+
+### Environment Files
+
+- `supabase/config.toml` — CLI project config
+- `supabase/.temp/` — CLI-generated files (do not commit)
+
+---
+
+## Global Design Tokens (global.css)
+
+| Token | Value |
+|---|---|
+| `--color-primary` | `#000000` |
+| `--color-on-primary` | `#ffffff` |
+| `--color-background` | `#fcf8fa` |
+| `--color-surface-lowest` | `#ffffff` |
+| `--color-on-surface` | `#111827` |
+| `--color-on-surface-variant` | `#6b7280` |
+| `--font-heading` | `"Montserrat", sans-serif` |
+| `--font-body` | `"Inter", system-ui, sans-serif` |
+| `--sidebar-width` | `280px` |
+| `--container-max` | `800px` |
+
+---
+
+## Recent Implementation
+
+### Backend Authentication (completed 2026-08-18)
+
+The sign-in/sign-out flow was moved from the frontend (direct Supabase Auth calls) to the backend (cookie-based proxy).
+
+#### What was changed
+
+| File | Change |
+|---|---|
+| `backend/src/routes/auth.js` | New — `POST /api/auth/signup`, `/signin`, `/signout`; `GET /api/auth/me` |
+| `backend/src/middleware/supabaseAuth.js` | Updated — reads JWT from `padhle.token` httpOnly cookie instead of `Authorization` header |
+| `backend/src/index.js` | Updated — added `cookie-parser` middleware; applied `supabaseAuth` to `/api/chat` and `/api/sessions` |
+| `backend/src/stores/sessionStore.js` | Updated — stores `userId` per session; `listSessions(userId)` filters by user |
+| `frontend/src/lib/auth/backendAuth.js` | New — frontend auth client (`signup`, `signin`, `signout`, `me`, `check`) |
+| `frontend/src/lib/auth/SupabaseAuth.jsx` | Updated — removed all `supabase.auth.*` calls; now uses backend client |
+
+#### How it works
+
+```
+1. User enters email + password → Frontend POST /api/auth/signin
+2. Backend calls Supabase Auth API → receives JWT
+3. Backend sets httpOnly cookies:
+ - padhle.token = JWT (for API verification)
+ - padhle.user = JSON({ uid, email }) (safe user metadata)
+4. Frontend never sees the JWT — browser stores cookies automatically
+5. All API calls include cookies via `credentials: 'include'`
+6. Backend middleware reads `padhle.token`, verifies via `/auth/v1/user`
+7. If valid, `req.user = { uid, email }` is attached to request
+```
+
+#### Verification
+- ✅ Build passes (Vite 6.4.3)
+- ✅ Sign-in → user authenticated, sees main app
+- ✅ Sign-out → cookies cleared, back to sign-in modal
+- ✅ Protected routes (`/api/sessions`) properly require auth (401 on missing cookie)
+- ✅ `padhle.token` cookie confirmed as `HttpOnly` in browser DevTools
+- ✅ No `localStorage` tokens used anywhere in frontend
+
+---
+
+## Security Audit — Vulnerabilities & Fixes
+
+An audit was performed on all backend files on 2026-08-18.
+
+### 🔴 Critical — Fixed
+
+#### CVE-2026-011: Cross-user identity leak via truncated token cache key
+**File:** `backend/src/middleware/supabaseAuth.js`
+
+**Problem:** `verifyToken` cached verified users keyed by `token.substring(0, 50)`. Every JWT from the same GoTrue instance shares the header (including `kid`) and the opening claims, so users A and B produced **identical 50-char fingerprints**. B's request then hit A's cached entry and `req.user.uid` resolved to A — so B's `/api/sessions` returned A's sessions/history. Reproduced end-to-end: two fresh users, A created a session, B listed A's session.
+
+**Fix:** Key the cache by the **full token**, which is unique per user:
+```js
+const cached = tokenCache.get(token); // full token
+...
+tokenCache.set(token, { ...result, expires }); // full token
+```
+Removed the `FINGERPRINT_LEN` constant. `listSessions`/RLS filtering was already correct; the leak was purely identity resolution.
+
+**Verified:** On fixed code, A has 1 session, B has 0; no cross-user leak. (Note: the running backend on :3001 must be restarted to load the fix — it runs `node src/index.js` without `--watch`.)
+
+
+#### CVE-2026-001: IDOR on `GET /api/chat/:chatId`
+**File:** `backend/src/routes/chat.js`
+
+**Problem:** The route checks `supabaseAuth` middleware (so user is authenticated), but never verifies the session belongs to `req.user.uid`. Any authenticated user can read another user's conversation history by guessing a `chatId`.
+
+**Fix:** Add ownership check before returning session data:
+```js
+router.get("/:chatId", (req, res) => {
+ const session = getSession(req.params.chatId);
+ if (!session || session.userId !== req.user.uid) {
+ return res.status(404).json({ error: "Session not found" });
+ }
+ // ... rest of route
+});
+```
+Also apply to `GET /api/chat/sessions` to filter by `req.user.uid`.
+
+---
+
+#### CVE-2026-002: No rate limiting on auth endpoints
+**File:** `backend/src/routes/auth.js`
+
+**Problem:** Zero throttling on signup/signin/signout. Allows unlimited password brute-force, account enumeration via signup attempts, and session flooding.
+
+**Fix:** Apply `express-rate-limit` to auth routes:
+```js
+import rateLimit from 'express-rate-limit';
+const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 20 });
+router.post('/signup', authLimiter, ...);
+router.post('/signin', authLimiter, ...);
+router.post('/signout', authLimiter, ...);
+```
+
+---
+
+#### CVE-2026-003: SSE stream injection — untrusted data in `data: ` prefix
+**File:** `backend/src/routes/chat.js`
+
+**Problem:** User messages (AI responses built from user input) are written raw into SSE events:
+```js
+res.write(`data: ${JSON.stringify({ type: "chunk", content: chunk })}\n\n`);
+```
+If `chunk` contains `}\n\n` or other control characters, it breaks the SSE stream and can inject fake events.
+
+**Fix:** Sanitize the JSON output before writing:
+```js
+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`);
+```
+
+---
+
+#### CVE-2026-004: Google Gemini API URL path break
+**File:** `backend/src/services/ai.js`
+
+**Problem:** API key concatenated into URL path — if the key contains `/` or `..`, the URL breaks:
+```js
+const url = `${GOOGLE_BASE_URL}${apiKey}/chat/models/...`;
+// = https://generativelanguage.googleapis.com/v1beta/openai/{key}/chat/... ← wrong
+```
+
+**Fix:** Use `URL` constructor to build safely:
+```js
+const url = new URL(`chat/models/${model}:streamGenerateContent`, GOOGLE_BASE_URL);
+url.searchParams.set('key', apiKey);
+```
+
+---
+
+### 🟡 High — Fixes Applied
+
+#### CVE-2026-005: Supabase error details leaked to client
+**File:** `backend/src/routes/auth.js`
+
+**Problem:** `data.error_description` from Supabase returns detailed error text (e.g., "Email already registered") that reveals system behavior.
+
+**Fix:** Return generic messages from the backend; log raw errors server-side only:
+```js
+// Log the real error
+console.error(`Auth error: ${data.error_description}`);
+// Return generic message
+return res.status(400).json({ error: "Sign up failed" });
+```
+
+---
+
+#### CVE-2026-006: No timeout on Supabase JWT verification HTTP calls
+**File:** `backend/src/middleware/supabaseAuth.js`
+
+**Problem:** Every request to a protected route makes an HTTP call to `Supabase /auth/v1/user` with no timeout. If Supabase is slow or unreachable, the Express server hangs indefinitely.
+
+**Fix:** Add `AbortSignal` timeout:
+```js
+const controller = new AbortController();
+const timeout = setTimeout(() => controller.abort(), 3000);
+const res = await fetch(url, { headers, signal: controller.signal });
+clearTimeout(timeout);
+```
+
+---
+
+#### CVE-2026-007: No token caching — every request hits Supabase
+**File:** `backend/src/middleware/supabaseAuth.js`
+
+**Problem:** Every single request makes an HTTP call to verify the token. A token is valid for 3600s; it should be cached.
+
+**Fix:** Cache verified tokens in a `Map` with TTL:
+```js
+const tokenCache = new Map(); // tokenFingerprint -> { uid, email, expires }
+const FINGERPRINT_TTL = 5 * 60 * 1000;
+const TOKEN_EXPIRY_TTL = 3600 * 1000;
+
+export async function verifyToken(token) {
+ const fp = token.substring(0, 50);
+ const cached = tokenCache.get(fp);
+ if (cached && Date.now() < cached.expires) {
+ return cached;
+ }
+ // ... verify via Supabase, then:
+ tokenCache.set(fp, { uid, email, expires: Date.now() + FINGERPRINT_TTL });
+ return { uid, email };
+}
+```
+
+---
+
+### 🟢 Medium — Fixes Applied
+
+#### CVE-2026-008: No input length limits on chat messages
+**File:** `backend/src/routes/chat.js`
+
+**Problem:** No max on `text` length. Can fill the in-memory store or blow up the AI context window.
+
+**Fix:** Add length check:
+```js
+if (!text || !text.trim()) return 400;
+if (text.length > 10000) return 400;
+```
+
+---
+
+#### CVE-2026-009: No XSS sanitization on stored messages
+**File:** `backend/src/stores/sessionStore.js`
+
+**Problem:** User messages stored and returned raw. If the frontend renders them as HTML, user input containing `