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
This commit is contained in:
+668
@@ -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 && (<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 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 `<script>` tags is a stored XSS vector.
|
||||
|
||||
**Fix:** Backend should at least flag or truncate dangerous content:
|
||||
```js
|
||||
const XSS_PATTERN = /[<>"'&]/;
|
||||
if (XSS_PATTERN.test(message.text)) {
|
||||
message.text = message.text.replace(/[<>]/g, (c) => c === '<' ? '<' : '>');
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 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/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.
|
||||
Reference in New Issue
Block a user