# 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 && (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, public by design) | `sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH` | | **Secret Key** (server-side) | `` — **rotated 2026-08-19; never commit the real value** | > **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**. > **Security note:** the previous `sb_secret_...` value was committed to git in this doc, `IMPLEMENTATION_PLAN.md`, and `backend/scripts/setup-db.js`. All three were scrubbed; rotate the local keys (`npx supabase stop && npx supabase start` or Studio) and update only `backend/.env`. ### 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 `