Files
padhle/ANALYSIS.md
T
hahachait ff9da4d324 feat: auth, persistence, and AI integration
- Cookie-based auth via backend proxy (httpOnly JWTs)
- Supabase Postgres persistence for sessions/messages/profiles + RLS
- Fix cross-user session leak (token cache keyed by full token, not 50-char prefix)
- Fix missing table grants (42501) via migration; auto-provision profiles on user creation
- Chat validation, ownership checks, rate limiting, /api/chat/sessions route ordering
- Frontend auth-state reset + credentials include
- OpenRouter AI provider (OpenAI-compatible base URL, reasoning disabled)
- Tests: chatValidation, appState
2026-08-19 09:41:40 -04:00

30 KiB
Raw Blame History

padhle — Codebase Analysis & Feature Tracker

File Map

File Purpose
frontend/src/App.jsx Main orchestrator — owns all state, SSE chat logic, session CRUD, auth gating
frontend/src/styles/global.css Global CSS variables (colors, fonts, spacing, radius, shadows)
frontend/src/main.jsx React entry point — wraps App in AuthProvider
frontend/src/lib/auth/SupabaseAuth.jsx Auth context — signIn, signUp, signOut, getToken (all via backend)
frontend/src/lib/auth/backendAuth.js Backend auth client — signup, signin, signout, me, check
frontend/src/lib/auth/SignIn.jsx Sign-in/Sign-up modal form
frontend/src/lib/auth/SignIn.css Modal styles
frontend/src/components/selector-flow/SelectorFlow.jsx Generic step-based selector — grade, subject, chapter (grid layout, conditional icons)
frontend/src/components/selector-flow/SelectorFlow.css Grid layout for grade options
frontend/src/components/top-nav/TopNav.jsx Header with grade/subject/chapter dropdown pills + search/notifications/user avatar
frontend/src/components/top-nav/TopNav.css Pill buttons, dropdown menus, avatar, sign-out styles
frontend/src/components/sidebar/Sidebar.jsx Brand, New Chat button, subjects list, chat history list, user info, sign-out, upgrade card
frontend/src/components/sidebar/Sidebar.css Sidebar layout, hover indicators, chat item delete, upgrade card, user info
frontend/src/components/chat-history/ChatHistory.jsx Welcome screen with bento suggestions OR message list OR selector flow
frontend/src/components/chat-history/ChatHistory.css Welcome header, bento grid, info panel, message list
frontend/src/components/message/Message.jsx Single message bubble (user or assistant), action buttons, typing dots
frontend/src/components/message/Message.css Bubble styling, avatar, actions, typing animation
frontend/src/components/chat-input/ChatInput.jsx Textarea with auto-expand, mic, send button, attachment
frontend/src/components/chat-input/ChatInput.css Input bar styling, send button states, disclaimer
backend/src/index.js Express app setup, middleware, route registration
backend/src/middleware/cookies.js Cookie parser middleware (cookie-parser)
backend/src/middleware/supabaseAuth.js JWT verification middleware — reads from padhle.token httpOnly cookie
backend/src/routes/auth.js POST /api/auth/signup, /api/auth/signin, /api/auth/signout; GET /api/auth/me
backend/src/routes/chat.js POST /api/chat (stream), GET /api/chat/sessions, GET /api/chat/:id (load session+messages); validates metadata and enforces ownership
backend/src/routes/sessions.js GET/POST/DELETE/PATCH /api/sessions; explicit ownership checks
backend/src/services/db.js Supabase database service for profiles, sessions, and messages
backend/src/routes/chatValidation.js Pure chat input validation and session ownership helpers
backend/src/stores/sessionStore.js Legacy in-memory Map-based session + message store; no longer used by chat/session routes
frontend/src/appState.js Central default/reset state for auth transitions
backend/src/services/ai.js AI provider abstraction (OpenAI/Anthropic/Google), streaming, system prompt builder

Architecture

Frontend (React + Vite) ──SSE──▶ Backend (Express) ──API──▶ AI Provider
     │                              │
     ├─ State: activeGrade,        └─ Supabase Postgres service
        activeSubject,                  └─ Explicit req.user.uid ownership checks
        activeChapter,
        messages,
        selectorStep (grade → subject → chapter → chat)
     │
     └─ Auth: httpOnly cookies (padhle.token, padhle.user)
        └─ Browser API requests use credentials: 'include'

Authentication — How It Works

All authentication is handled server-side via httpOnly cookies. The frontend never sees raw tokens.

Flow

1. User enters email + password → Frontend POST /api/auth/signin
2. Backend calls Supabase Auth API → receives JWT
3. Backend sets httpOnly cookies:
   - padhle.token = JWT (for API verification)
   - padhle.user = JSON({ uid, email }) (safe user metadata)
4. Frontend never sees the JWT — browser stores cookies automatically
5. All API calls include cookies via credentials: 'include'
6. Backend middleware reads padhle.token, verifies via /auth/v1/user
7. If valid, req.user = { uid, email } is attached to request

Auth Endpoints

Route Method Purpose Auth Required
/api/auth/signup POST Create account via Supabase Auth No
/api/auth/signin POST Sign in via Supabase Auth No
/api/auth/signout POST Revoke session + clear cookies Yes
/api/auth/me GET Get current user from cookie Yes

Protected Routes

Route Middleware Purpose
/api/chat supabaseAuth Send message, load conversation
/api/sessions supabaseAuth CRUD chat sessions

Security

  • httpOnly cookies: JWT is never accessible to JavaScript
  • sameSite: lax: CSRF protection
  • secure flag: Enabled in production (HTTPS)
  • JWT verification: Each request calls Supabase Auth server to validate
  • No localStorage tokens: Sensitive data never stored in browser
  • Session revocation: Signout calls Supabase /auth/v1/logout server-side

Grade Selector — Current State

Where it lives

Component File Role
SelectorFlow frontend/src/components/selector-flow/SelectorFlow.jsx Generic step-based selector — grade, subject, chapter (grid layout)
TopNav grade pill frontend/src/components/top-nav/TopNav.jsx (lines ~46-75) Always-visible header dropdown
State owner frontend/src/App.jsx selectorStep + activeGrade/Subject/Chapter state
ChatHistory frontend/src/components/chat-history/ChatHistory.jsx Renders SelectorFlow when selectorStep !== "chat"

Sequential selector flow

New Chat → selectorStep = "grade"
  → Pick Grade → selectorStep = "subject" → fade-in
    → Pick Subject → selectorStep = "chapter" → fade-in (subject-aware chapters)
      → Pick Chapter → selectorStep = "chat" → welcome screen / messages

State values

State Type Default Stored in DB
activeGrade label "Choose standard" "Grade 10"
activeSubject ID "choose-subject" "math"
activeChapter label "choose-chapter" "Chapter 1: Rational Numbers"
selectorStep enum "grade"
Value Used in
"Choose standard" Default for activeGrade
Grade 6 .. Grade 12 SelectorFlow + TopNav grade options
"math" .. "geography" activeSubject IDs (subjectItems)
"choose-subject" Default/placeholder for activeSubject
"choose-chapter" Default/placeholder for activeChapter

Data flow

User clicks grade in SelectorFlow
  → handleGradeSelect(item) → setActiveGrade(item.label) → pill shows label

User clicks subject in SelectorFlow
  → handleSubjectSelect(item) → setActiveSubject(item.id) → "math"
    → getSubjectLabel("math") → "Mathematics" (for pill/placeholder display)
    → handleChatSelect restores: data.session.subject = "math" → matches subject.id

User clicks subject in Sidebar
  → onSubjectChange(id) → setActiveSubject(id) → resets selectorStep to "subject"

User clicks subject in TopNav dropdown
  → selectOption("subject", subject.id) → onSubjectChange(id)
    → setActiveSubject(id) → subject-aware chapter dropdown updates

User clicks grade/chapter in TopNav
  → selectOption("grade", grade.label) / selectOption("chapter", chapter.label)
    → directly updates state

Subject label resolution (central pattern)

activeSubject (stores ID) = "math"
  → getSubjectLabel("math") → "Mathematics"
    → TopNav pill: resolvedSubjectLabel
    → ChatInput placeholder: `${getSubjectLabel(activeSubject)}`
    → SelectorFlow subtitle: `Subject  ${getSubjectLabel(activeSubject)}`
    → TopNav dropdown: activeSubject === subject.id → highlights Mathematics
    → Sidebar: activeSubject === subject.id → highlights Mathematics

Chapter label resolution

activeChapter (stores ID/label) = "choose-chapter" → getChapterLabel("choose-chapter") → "Choose Chapter"
activeChapter (stored label) = "Chapter 1: Rational Numbers" → getChapterLabel(...) → "Chapter 1: Rational Numbers"
  → TopNav pill: resolvedChapterLabel
  → ChatInput placeholder: `${getChapterLabel(activeChapter)}`

TopNav chapter dropdown (subject-aware)

chapterData[activeSubject] → chapterOptions array
  → ["Choose Chapter", ...chaptersFromSubject]
  → Changes dynamically when activeSubject changes
  → Previously: static list mixing chapters from all subjects

How grade is used

  • Sent in POST /api/chat body → grade field (only when not default)
  • Used in AI system prompt via buildSystemPrompt()"You are teaching students in grade X"
  • Displayed in chat input placeholder: "Ask anything about {grade} {subject} {chapter}..."
  • Restored when loading a session from data.session.grade

SelectorFlow component

  • Generic: accepts title, subtitle, items, selectedValue, onSelect, step, compareField props
  • compareField determines which property to compare against selectedValue for active highlighting:
    • "label"selectedValue === item.label (used for grades and chapters)
    • "id"selectedValue === item.id (used for subjects)
  • Renders any item type via 3-column grid with fade-in animation
  • Conditional icon rendering: item.icon && (<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:

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:

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 === '<' ? '&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.

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.

What's Next

1. Frontend chat-flow design gap

  • Backend does not return the new session.id to the client (SSE only sends chunk/done/error).
  • Sidebar refresh (loadSessions()) only runs on the successful SSE path, so an AI error leaves the sidebar stale even though the session row is created.
  • Decide: return session.id in SSE + refresh sidebar on error path (Option 1), or only persist sessions after a successful AI response (Option 2).

2. Frontend authentication handling

  • Handle protected-route HTTP 401 responses by returning to the sign-in modal.
  • Persist selector state 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: ~deepseek/deepseek-v4-flash-latest (the ~ prefix is REQUIRED — it is OpenRouter's "redirect to latest" alias; the non-tilde deepseek/deepseek-v4-flash-latest is an invalid model ID).
  • Verified end-to-end: /api/chat streams a real reply and persists both user and assistant messages (lost messages bug from the no-AI path is gone).
  • Note: this is a reasoning model — it streams thinking in delta.reasoning before delta.content; the app reads delta.content, so there is a brief (sub-second) gap while reasoning, then the answer streams.

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.