- 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).
34 KiB
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/logoutserver-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/chatbody →gradefield (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,compareFieldprops compareFielddetermines which property to compare againstselectedValuefor 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 selectorFadeInwithopacity+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
chapterDataobject 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
selectorStepstate) - 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)
activeSubjectnow 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,
handleSendbody handleSubjectSelectstoresitem.id(notitem.label)handleChatSelectrestoration: backend returnssubject: "math"which matchessubject.id- TopNav: uses
subjectItemsprop;activeSubject === subject.idcomparison works; pill usesresolvedSubjectLabel - TopNav chapter dropdown: uses
chapterDataprop; generateschapterOptions[activeSubject]dynamically (subject-aware) - Sidebar:
activeSubject === subject.idcomparison now works correctly; sidebar subject change resets selector tosubjectstep - SelectorFlow:
compareField="id"for subjects,compareField="label"for grades/chapters - ChatInput placeholder: uses
getSubjectLabel(activeSubject)andgetChapterLabel(activeChapter)for full label resolution - Files:
App.jsx,TopNav.jsx,Sidebar.jsx,SelectorFlow.jsx,ChatInput.jsx
Chapter pill resolution (✅ verified in browser)
activeChapterstores 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
chapterOptionsgenerated fromchapterData[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
activeChapteris"choose-chapter" - Files:
TopNav.jsx(chapterOptions generation, chapterData prop),App.jsx(chapterData pass-through)
Upgrade-to-Pro card hidden (✅ verified)
Sidebarcomponent acceptsshowUpgradeprop (defaulttrue)App.jsxpassesshowUpgrade={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:
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→ thensupabase_mcp_connect()
Database State
- Custom tables:
profiles,sessions, andmessagesexist 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 toanon/authenticated/service_role; tracked insupabase/migrations/20260819120000_grant_api_role_table_access.sql, which also setsALTER DEFAULT PRIVILEGESso future tables inherit the grants. - Profiles (control):
profileshasrole(defaultuser) andis_banned(defaultfalse) columns. A trigger onauth.usersauto-creates a profile row on any user creation (signup or admin).authenticatedmay update onlydisplay_name/avatar_url— it cannot changerole/is_banned;service_rolehas 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/profilesare empty; no admin user yet. Admin user is added manually in Studio (Auth → Add user, email_confirm on) and then promoted torole='admin'. - Verified: self-signup and admin-created users both auto-create a
profilesrow withrole:'user'; the role is_banned self-edit is blocked at the column-privilege level.
Environment Files
supabase/config.toml— CLI project configsupabase/.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.tokencookie confirmed asHttpOnlyin browser DevTools - ✅ No
localStoragetokens 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:
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:
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:
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:
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:
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:
const url = `${GOOGLE_BASE_URL}${apiKey}/chat/models/...`;
// = https://generativelanguage.googleapis.com/v1beta/openai/{key}/chat/... ← wrong
Fix: Use URL constructor to build safely:
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:
// 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:
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:
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:
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:
const XSS_PATTERN = /[<>"'&]/;
if (XSS_PATTERN.test(message.text)) {
message.text = message.text.replace(/[<>]/g, (c) => c === '<' ? '<' : '>');
}
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 1–5 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/sessionsis registered beforeGET /api/chat/:chatId, preventingsessionsfrom 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.jsandfrontend/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
useAuthimport 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
sessionid 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, andmessagestables exist. - Table grants were missing, causing
42501 permission denied. Fixed and verified (see Database State above); migration tracked insupabase/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_idkeeps 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/chatcall 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 setsactiveChaton 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.summarycolumn (migration20260819150000_add_sessions_summary.sql).- On each
/api/chat, when a session exceedsRECENT_LIMIT(10) messages, older messages are folded into a persisted summary via a non-streaming model call (summarizeConversation), stored withsetSummary. - 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.ReactMarkdownescapes 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 toai.jsas an optional override).- Model:
qwen/qwen3.7-flash(final choice; earlier tried~deepseek/deepseek-v4-flash-latestandupstage/solar-pro4).- OpenRouter "latest" aliases use a required
~prefix (e.g.~deepseek/...); the non-tilde-latestis an invalid ID.
- OpenRouter "latest" aliases use a required
- Reasoning disabled for OpenRouter (
reasoning: { enabled: false }instreamOpenai/getChatResponse) sodelta.contentstreams immediately instead of sitting empty during the thinking phase (which made the chat look blank). - Verified end-to-end:
/api/chatstreams a real reply and persists bothuserandassistantmessages. - 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
.envsecrets 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.