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