Files
padhle/ANALYSIS.md
T
hahachait 534b51b41b feat: rolling conversation summary + session continuity
- Backend emits session id in SSE; frontend sets activeChat so follow-ups
  reuse the same session (fixes fragmented history).
- sessions.summary column; messages past RECENT_LIMIT (10) are folded into
  a persisted summary via a non-streaming model call.
- buildMessages sends system(+summary) + last 10 messages, bounding context
  while preserving long-range memory.
- Tests: aiMessages (capping + summary injection).
2026-08-19 10:23:56 -04:00

707 lines
34 KiB
Markdown
Raw 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 — 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), markdown+math rendering for 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 && (<span>icon</span>)`
- 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 612), 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 612)
- 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 `<script>` tags is a stored XSS vector.
**Fix:** Backend should at least flag or truncate dangerous content:
```js
const XSS_PATTERN = /[<>"'&]/;
if (XSS_PATTERN.test(message.text)) {
message.text = message.text.replace(/[<>]/g, (c) => c === '<' ? '&lt;' : '&gt;');
}
```
---
#### CVE-2026-0010: AI keys exposed in process environment
**File:** `backend/src/services/ai.js`
**Problem:** API keys read from `process.env` and used in `fetch()` calls. On some systems they appear in `ps aux`.
**Fix:** Not a code fix — ensure `.env` is in `.gitignore`, use secret managers in production, and avoid `console.log` of config values.
---
## Current Implementation Status — 2026-08-19
### Completed and verified
- Backend cookie-based authentication remains implemented; frontend never reads JWTs.
- Auth, chat, and session rate limiters are installed and mounted.
- Auth rate-limit boundary verified: requests 15 returned validation responses; request 6 returned HTTP 429.
- Chat validation rejects empty text, text over 10,000 characters, invalid grades, and invalid subjects.
- Chat/session ownership checks are enforced against `req.user.uid`.
- `GET /api/chat/sessions` is registered before `GET /api/chat/:chatId`, preventing `sessions` from being interpreted as a chat ID.
- Frontend auth transitions clear user-scoped conversation state and abort in-flight requests.
- Frontend API requests include `credentials: "include"` for httpOnly cookies.
- Added regression tests: `backend/test/chatValidation.test.js` and `frontend/test/appState.test.js`.
- Backend tests, backend syntax checks, frontend regression test, and Vite production build pass.
- Chrome MCP verified the sign-in screen renders after fixing a missing `useAuth` import exposed by the browser console.
- Cross-user session leak fixed (token cache keyed by full token — see CVE-2026-011).
- AI connected via OpenRouter; streaming verified in the browser with both user+assistant messages persisted.
- Assistant replies render as markdown + KaTeX math; verified in the headed browser.
- Current user message is included in the AI context (persisted before streaming).
- Session continuity fixed: backend emits `session` id in SSE; follow-ups reuse the same session.
- Rolling conversation summary implemented and persisted (bounded context + long-range memory).
### Database status
- Supabase API is reachable at `http://127.0.0.1:54321`.
- The `profiles`, `sessions`, and `messages` tables exist.
- Table grants were missing, causing `42501 permission denied`. **Fixed and verified** (see Database State above); migration tracked in `supabase/migrations/20260819120000_grant_api_role_table_access.sql`.
---
## Data Storage & Conversation Context Model (2026-08-19)
### Q: Each message is a new row — is that sustainable?
**A (yes):** Each message is one row in `public.messages` (`id`, `session_id`, `role`, `text`, `created_at`), written append-only. This is the standard chat design (Slack/OpenAI-style). It is sustainable because:
- Storage is tiny (average turn is ~KB scale; a million messages ≈ a few GB).
- `idx_messages_session_id` keeps per-session history reads fast even with many rows.
- Append-only is desirable: simple pagination/partial loads, immutable history, easy to add edit/regenerate later.
- One-row-per-session-with-big-text-blob would be worse (can't load partial history, bad indexing).
- DB only needs attention at scale: partitioning by date/user past ~10s of millions of rows, or archiving old sessions.
### The real scaling concern: AI context window (the "memory" issue)
- The DB is fine, but on **every** `/api/chat` call the backend loads the **entire** message history and sends it **all** to the model.
- Token count (and therefore **cost and latency**) grows with conversation length; eventually it exceeds the model's context limit and the call fails.
- **This is the lever worth engineering** — not the database.
### Blocked-on note
Before context work has value, the **frontend session-continuity bug** must be fixed (see What's Next #1): follow-ups in a fresh chat currently create a new session with no history, so long-running context never even accumulates.
---
## What's Next
### 1. Frontend session continuity — DONE
- Backend now emits a `{ type: "session", id }` SSE event when a conversation is created/loaded; the frontend sets `activeChat` on it, so follow-ups reuse the same session and history accumulates.
- Remaining minor: sidebar refresh (`loadSessions()`) only runs on the successful SSE path — an AI error still leaves the sidebar stale even though the session row was created.
### 1b. Rolling conversation summary (B+D) — implemented 2026-08-19
- `sessions.summary` column (migration `20260819150000_add_sessions_summary.sql`).
- On each `/api/chat`, when a session exceeds `RECENT_LIMIT` (10) messages, older messages are folded into a persisted summary via a non-streaming model call (`summarizeConversation`), stored with `setSummary`.
- The model receives `system(+summary) + last 10 messages` (`buildMessages`), so context stays bounded while long-range context persists.
- Verified end-to-end: fresh chat emits session id; a 12-message session folded its overflow into a stored summary; follow-up answered with bounded context.
- Tests: `backend/test/aiMessages.test.js` (capping + summary injection).
### 2. Frontend rendering — fixed
- Assistant replies now render via `react-markdown` (headings, bold, italics, lists, blockquotes, code, tables, `---`) and math (`$H_2O$`, `$CO_2$`) via KaTeX. User messages stayed plain text. `ReactMarkdown` escapes raw HTML by default, so stored-XSS (CVE-2026-009) stays closed while rendering rich content.
- Protected-route 401s still return to the sign-in modal.
- Selector state persists per user.
### AI Provider — OpenRouter (connected 2026-08-19)
- Backend uses the OpenAI-compatible path (`AI_PROVIDER=openai`) pointed at OpenRouter.
- `OPENAI_BASE_URL=https://openrouter.ai/api/v1` (added to `ai.js` as an optional override).
- Model: `qwen/qwen3.7-flash` (final choice; earlier tried `~deepseek/deepseek-v4-flash-latest` and `upstage/solar-pro4`).
- OpenRouter "latest" aliases use a required `~` prefix (e.g. `~deepseek/...`); the non-tilde `-latest` is an invalid ID.
- Reasoning disabled for OpenRouter (`reasoning: { enabled: false }` in `streamOpenai`/`getChatResponse`) so `delta.content` streams immediately instead of sitting empty during the thinking phase (which made the chat look blank).
- Verified end-to-end: `/api/chat` streams a real reply and persists both `user` and `assistant` messages.
- The user message is now persisted **before** streaming, so the model sees the current question (fixes canned-greeting responses) and the user message survives AI failures.
**Env (non-secret):** wire via `backend/.env`, and the placeholders are in `backend/.env.example`.
**Secrets:** `backend/.env` contains the real `OPENAI_API_KEY` (OpenRouter) plus `SUPABASE_SERVICE_ROLE_KEY`. Both are git-ignored; never commit. Rotate if exposed.
### Operational hardening
- Ensure `.env` secrets are never committed or exposed.
- Rotate any secret key that has been shared outside the local environment.
- Add monitoring for rate-limit hits and database errors.