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:
2026-08-19 09:41:40 -04:00
parent 93a35a7343
commit ff9da4d324
43 changed files with 4174 additions and 249 deletions
+8
View File
@@ -12,6 +12,14 @@ dist
dist-ssr dist-ssr
*.local *.local
# Supabase self-hosted
docker-compose.yml
.env
.env.*
!.env.example
volumes/
logs/
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json
+668
View File
@@ -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 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:**
```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 === '<' ? '&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.
+661
View File
@@ -0,0 +1,661 @@
# padhle — Implementation Plan
## Rate Limiting + Supabase Integration + Session Bug Fix
**Date:** 2026-08-19
**Total Estimated Time:** ~3 hours
**Status:** Ready to implement
---
## Issues to Address
### 🔴 Critical: Session Data Leak (User A ↔ User B)
**Symptoms:**
- User A logs in → chats with AI → logs out
- User B logs in → sidebar empty (correct)
- BUT User B sees User A's conversation on main screen ❌
**Root Cause:**
- Frontend `activeChat` state not cleared on sign-out
- In-memory backend store allows session ID guessing
- Stale `messages` state renders after User A logs out
---
### 🟡 Security: No Rate Limiting
- Auth endpoints (`/signup`, `/signin`) vulnerable to brute-force
- No per-IP throttling
---
### 🟠 Architecture: In-Memory Sessions
- Sessions lost on server restart
- Not tied to database — only scoped via cookies
- Need Supabase Postgres for persistence
---
## Implementation Phases
### **PHASE 1: Rate Limiting by IP (30 min)**
**Goal:** Prevent brute-force attacks on auth endpoints
#### 1.1 Install dependency
```bash
cd backend
npm install express-rate-limit
```
#### 1.2 Create middleware file
**File:** `backend/src/middleware/rateLimiter.js` (NEW)
```js
import rateLimit from 'express-rate-limit';
export const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 requests per window per IP
message: { error: 'Too many auth attempts. Try again later.' },
standardHeaders: false,
skip: (req) => process.env.NODE_ENV !== 'production' && req.ip === '::1',
});
export const chatLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 30, // 30 requests per minute
message: { error: 'Rate limit exceeded. Try again later.' },
standardHeaders: false,
});
export const sessionsLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 20, // 20 requests per minute
message: { error: 'Rate limit exceeded. Try again later.' },
standardHeaders: false,
});
```
#### 1.3 Apply to auth routes
**File:** `backend/src/routes/auth.js` (MODIFY top of file)
Add import:
```js
import { authLimiter } from '../middleware/rateLimiter.js';
```
Then apply to each route:
```js
router.post('/signup', authLimiter, async (req, res) => { ... });
router.post('/signin', authLimiter, async (req, res) => { ... });
router.post('/signout', authLimiter, async (req, res) => { ... });
```
#### 1.4 Apply to other routes
**File:** `backend/src/index.js` (MODIFY)
Add imports:
```js
import { chatLimiter, sessionsLimiter } from './middleware/rateLimiter.js';
```
Update middleware order:
```js
app.use('/api/chat', chatLimiter, supabaseAuth, chatRoutes);
app.use('/api/sessions', sessionsLimiter, supabaseAuth, sessionRoutes);
```
#### 1.5 Test
```bash
cd backend && npm start
# Test rate limiting (6th request should fail)
for i in {1..7}; do
curl -X POST http://localhost:3001/api/auth/signup \
-H 'Content-Type: application/json' \
-d "{\"email\": \"test$i@example.com\", \"password\": \"test\"}"
echo ""
done
# Expect 429 on 6th attempt
```
---
### **PHASE 2: Supabase Database Integration (90 min)**
**Goal:** Move sessions & messages from in-memory to Supabase Postgres with RLS
#### 2.1 Create database migration
**File:** `backend/supabase/migrations/{timestamp}_create_sessions_messages.sql` (NEW)
```sql
-- sessions table
CREATE TABLE public.sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
grade TEXT,
subject TEXT,
chapter TEXT,
preview TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- messages table
CREATE TABLE public.messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES public.sessions(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('user', 'assistant')),
text TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
-- profiles table (for future use)
CREATE TABLE public.profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
email TEXT NOT NULL,
display_name TEXT,
avatar_url TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Indexes
CREATE INDEX idx_sessions_user_id ON public.sessions(user_id);
CREATE INDEX idx_messages_session_id ON public.messages(session_id);
-- Enable RLS
ALTER TABLE public.sessions ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.messages ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
-- RLS: sessions
CREATE POLICY "Users view own sessions"
ON public.sessions FOR SELECT
USING (auth.uid() = user_id);
CREATE POLICY "Users create sessions"
ON public.sessions FOR INSERT
WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Users update own sessions"
ON public.sessions FOR UPDATE
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Users delete own sessions"
ON public.sessions FOR DELETE
USING (auth.uid() = user_id);
-- RLS: messages
CREATE POLICY "Users view own messages"
ON public.messages FOR SELECT
USING (session_id IN (SELECT id FROM public.sessions WHERE user_id = auth.uid()));
CREATE POLICY "Users insert own messages"
ON public.messages FOR INSERT
WITH CHECK (session_id IN (SELECT id FROM public.sessions WHERE user_id = auth.uid()));
CREATE POLICY "Users delete own messages"
ON public.messages FOR DELETE
USING (session_id IN (SELECT id FROM public.sessions WHERE user_id = auth.uid()));
-- RLS: profiles
CREATE POLICY "Users view own profile"
ON public.profiles FOR SELECT
USING (auth.uid() = id);
CREATE POLICY "Users update own profile"
ON public.profiles FOR UPDATE
USING (auth.uid() = id)
WITH CHECK (auth.uid() = id);
```
#### 2.2 Apply migration
```bash
cd /path/to/padhle
npx supabase db push
```
Verify in Supabase Studio (http://localhost:54323):
- Tables exist: `sessions`, `messages`, `profiles`
- RLS enabled on all three
- Policies listed under each table
#### 2.3 Create database service
**File:** `backend/src/services/db.js` (NEW)
```js
import { createClient } from '@supabase/supabase-js';
import dotenv from 'dotenv';
dotenv.config();
const SUPABASE_URL = process.env.SUPABASE_URL;
const SUPABASE_SERVICE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_KEY);
export async function createSession(userId, grade, subject, chapter) {
const { data, error } = await supabase
.from('sessions')
.insert({
user_id: userId,
grade: grade || 'General',
subject: subject || 'General',
chapter: chapter || 'General',
preview: 'New Chat',
})
.select()
.single();
if (error) throw new Error(`Failed to create session: ${error.message}`);
return data;
}
export async function getSession(sessionId) {
const { data, error } = await supabase
.from('sessions')
.select('*')
.eq('id', sessionId)
.single();
if (error && error.code === 'PGRST116') return null;
if (error) throw new Error(`Failed to get session: ${error.message}`);
return data;
}
export async function listSessions(userId) {
const { data, error } = await supabase
.from('sessions')
.select('*')
.eq('user_id', userId)
.order('updated_at', { ascending: false });
if (error) throw new Error(`Failed to list sessions: ${error.message}`);
return data || [];
}
export async function getMessages(sessionId) {
const { data, error } = await supabase
.from('messages')
.select('*')
.eq('session_id', sessionId)
.order('created_at', { ascending: true });
if (error) throw new Error(`Failed to get messages: ${error.message}`);
return data || [];
}
export async function addMessage(sessionId, role, text) {
const { data: messageData, error: msgError } = await supabase
.from('messages')
.insert({ session_id: sessionId, role, text })
.select()
.single();
if (msgError) throw new Error(`Failed to add message: ${msgError.message}`);
const { error: updateError } = await supabase
.from('sessions')
.update({
updated_at: new Date().toISOString(),
preview: role === 'user' ? text.substring(0, 60) + (text.length > 60 ? '...' : '') : undefined,
})
.eq('id', sessionId);
if (updateError) console.error('Failed to update session:', updateError);
return messageData;
}
export async function deleteSession(sessionId) {
const { error } = await supabase
.from('sessions')
.delete()
.eq('id', sessionId);
if (error) throw new Error(`Failed to delete session: ${error.message}`);
return true;
}
export async function clearMessages(sessionId) {
const { error } = await supabase
.from('messages')
.delete()
.eq('session_id', sessionId);
if (error) throw new Error(`Failed to clear messages: ${error.message}`);
return true;
}
```
#### 2.4 Install SDK
```bash
cd backend
npm install @supabase/supabase-js
```
#### 2.5 Update routes to use DB service
**File:** `backend/src/routes/chat.js` (MODIFY)
Replace top imports:
```js
// OLD
import { createSession, addMessage, getMessages, getSession } from "../stores/sessionStore.js";
// NEW
import { createSession, addMessage, getMessages, getSession } from "../services/db.js";
```
Update function calls (add `await`):
```js
// OLD: session = createSession(userId, grade, subject, chapter);
// NEW: session = await createSession(userId, grade, subject, chapter);
// OLD: addMessage(currentChatId, userMsg);
// NEW: await addMessage(currentChatId, userMsg.role, userMsg.text);
// OLD: getMessages(currentChatId)
// NEW: const messages = await getMessages(currentChatId);
// OLD: addMessage(currentChatId, assistantMsg);
// NEW: await addMessage(currentChatId, assistantMsg.role, assistantMsg.text);
```
Wrap in try-catch:
```js
try {
session = await createSession(...);
// ... rest of logic
} catch (err) {
return res.status(500).json({ error: 'Database error' });
}
```
**File:** `backend/src/routes/sessions.js` (MODIFY)
Same replacements as chat.js:
```js
import { listSessions, getSession, deleteSession, clearMessages, createSession } from "../services/db.js";
router.get('/', async (req, res) => {
try {
const sessions = await listSessions(req.user.uid);
res.json(sessions);
} catch (err) {
res.status(500).json({ error: 'Failed to load sessions' });
}
});
// ... same for POST, GET/:id, DELETE/:id, PATCH/:id/clear
```
#### 2.6 Update .env
**File:** `backend/.env` (ADD)
```
SUPABASE_SERVICE_ROLE_KEY=sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz
```
(Get key from `npx supabase status`)
#### 2.7 Test integration
```bash
cd backend && npm start
# Create a session
curl -X POST http://localhost:3001/api/sessions \
-H 'Content-Type: application/json' \
-H 'Cookie: padhle.token=<valid_jwt>' \
-d '{"grade": "Grade 10", "subject": "math", "chapter": "Chapter 1"}'
# Check Supabase Studio: http://localhost:54323
# → Tables → sessions → should see new row
```
---
### **PHASE 3: Fix Session Leak Bug (45 min)**
**Goal:** Ensure User A's data not visible to User B
#### 3.1 Fix frontend state cleanup
**File:** `frontend/src/App.jsx` (MODIFY)
Find `useEffect([isAuthenticated])` around line 85. Replace entire block:
```js
useEffect(() => {
if (!hasMountedRef.current) {
hasMountedRef.current = true;
} else if (isAuthenticated) {
// User just signed in after sign-out
setMessages([]);
setActiveChat(null); // CRITICAL: clear before loading
setActiveGrade(DEFAULTS.grade);
setActiveSubject(DEFAULTS.subject);
setActiveChapter(DEFAULTS.chapter);
setIsTyping(false);
setSelectorStep('grade');
loadSessions(); // Load fresh sessions for current user
} else {
// User just signed out
setMessages([]);
setActiveChat(null);
setActiveGrade(DEFAULTS.grade);
setActiveSubject(DEFAULTS.subject);
setActiveChapter(DEFAULTS.chapter);
setIsTyping(false);
setSelectorStep('grade');
setSessions([]);
}
}, [isAuthenticated]);
```
#### 3.2 Add safety check in ChatHistory
**File:** `frontend/src/components/chat-history/ChatHistory.jsx` (MODIFY at top of component)
Add safety check before rendering:
```js
function ChatHistory({ messages, sessions, activeChat, selectorStep, setMessages, setActiveChat, ...props }) {
// Safety: verify activeChat session still exists
const sessionExists = activeChat && sessions.some(s => s.id === activeChat);
if (selectorStep !== 'chat') {
return <SelectorFlow {...props} />;
}
if (!sessionExists && messages.length > 0) {
// Stale state detected — clear
setMessages([]);
setActiveChat(null);
return <WelcomeScreen />;
}
if (messages.length === 0) {
return <WelcomeScreen />;
}
return <MessageList messages={messages} />;
}
```
#### 3.3 Verify RLS on backend
**File:** `backend/src/routes/sessions.js` (VERIFY ownership check)
```js
router.get('/:id', async (req, res) => {
try {
const userId = req.user.uid;
const session = await getSession(req.params.id);
// Defense-in-depth: explicit ownership check
if (!session || session.user_id !== userId) {
return res.status(404).json({ error: 'Session not found' });
}
res.json(session);
} catch (err) {
res.status(500).json({ error: 'Failed to load session' });
}
});
```
#### 3.4 E2E Test
```
1. Open Window 1 (Firefox)
2. Sign in as User A
3. Send message → create session
4. Verify session in sidebar ✓
5. Sign out
6. Open Window 2 (Chrome) or incognito tab
7. Sign in as User B
8. Verify:
- Sidebar empty ✓
- Main screen shows welcome (NOT User A's chat) ✓
9. Send a message → create new session
10. Sign out User B
11. In Window 1: Sign back in as User A
12. Verify User A sees original session ✓
```
---
### **PHASE 4: Error Handling & Validation (30 min)**
#### 4.1 Sanitize AI provider errors
**File:** `backend/src/services/ai.js` (MODIFY error handlers)
In `streamAnthropic`:
```js
if (!response.ok) {
const errText = await response.text();
console.error('Anthropic error:', errText); // Log server-side only
onError('AI service encountered an error. Please try again.');
return '';
}
```
In `streamGoogle`:
```js
if (!response.ok) {
const errText = await response.text();
console.error('Google error:', errText); // Log server-side only
onError('AI service encountered an error. Please try again.');
return '';
}
```
#### 4.2 Fix Anthropic non-streaming endpoint
**File:** `backend/src/services/ai.js` (MODIFY line ~138)
```js
// OLD
const res = await fetch(ANTHROPIC_BASE_URL, {
// NEW
const res = await fetch(`${ANTHROPIC_BASE_URL}/messages`, {
```
#### 4.3 Validate metadata
**File:** `backend/src/routes/chat.js` (ADD at top)
```js
const VALID_GRADES = ['Grade 6', 'Grade 7', 'Grade 8', 'Grade 9', 'Grade 10', 'Grade 11', 'Grade 12', 'General', 'Choose standard'];
const VALID_SUBJECTS = ['math', 'science', 'history', 'language', 'english', 'geography', 'General', 'choose-subject'];
```
Then in POST handler:
```js
if (grade && !VALID_GRADES.includes(grade)) {
return res.status(400).json({ error: 'Invalid grade' });
}
if (subject && !VALID_SUBJECTS.includes(subject)) {
return res.status(400).json({ error: 'Invalid subject' });
}
```
#### 4.4 Test error paths
```bash
# Invalid grade
curl -X POST http://localhost:3001/api/chat \
-H 'Cookie: padhle.token=<jwt>' \
-H 'Content-Type: application/json' \
-d '{"text": "hello", "grade": "invalid"}'
# Expect 400
# Message too long
curl -X POST http://localhost:3001/api/chat \
-H 'Cookie: padhle.token=<jwt>' \
-H 'Content-Type: application/json' \
-d '{"text": "'$(printf 'x%.0s' {1..10001})'}'
# Expect 400
```
---
## Summary Table
| Phase | Task | Time | Dependencies |
|-------|------|------|--------------|
| 1.1 | Install `express-rate-limit` | 2 min | — |
| 1.2 | Create `rateLimiter.js` | 5 min | — |
| 1.3 | Apply to auth.js | 5 min | 1.2 |
| 1.4 | Apply to index.js | 5 min | 1.3 |
| 1.5 | Test rate limiting | 13 min | 1.11.4 |
| **Phase 1 Total** | **Rate Limiting** | **30 min** | — |
| 2.1 | Create SQL migration | 10 min | — |
| 2.2 | Apply migration | 5 min | 2.1 |
| 2.3 | Create `db.js` | 20 min | 2.2 |
| 2.4 | Install SDK | 3 min | — |
| 2.5 | Update chat.js & sessions.js | 20 min | 2.3, 2.4 |
| 2.6 | Update .env | 2 min | — |
| 2.7 | Test integration | 15 min | 2.12.6 |
| **Phase 2 Total** | **DB Integration** | **75 min** | Phase 1 (optional) |
| 3.1 | Fix App.jsx state cleanup | 10 min | — |
| 3.2 | Add ChatHistory safety check | 10 min | 3.1 |
| 3.3 | Verify RLS in sessions.js | 5 min | 2.7 |
| 3.4 | E2E test | 20 min | 3.13.3 |
| **Phase 3 Total** | **Fix Session Leak** | **45 min** | Phase 2 |
| 4.1 | Sanitize AI errors | 5 min | — |
| 4.2 | Fix Anthropic endpoint | 2 min | — |
| 4.3 | Validate metadata | 10 min | — |
| 4.4 | Test error paths | 13 min | 4.14.3 |
| **Phase 4 Total** | **Error Handling** | **30 min** | — |
| | | | |
| **GRAND TOTAL** | **All Phases** | **~180 min (3 hrs)** | |
---
## Verification Checklist
Before marking complete, verify:
- [ ] Phase 1: Rate limiting blocks 6th auth attempt
- [ ] Phase 2: Sessions stored in Supabase (check Studio)
- [ ] Phase 2: User A's session NOT visible to User B via API
- [ ] Phase 3: Frontend clears state on sign-out
- [ ] Phase 3: User B sees welcome screen (not User A's chat) after sign-in
- [ ] Phase 3: E2E test passes (User A → chat → signout → User B → fresh state)
- [ ] Phase 4: Invalid grade returns 400
- [ ] Phase 4: AI errors return generic message to client
- [ ] Phase 4: Anthropic non-streaming calls work
- [ ] All tests pass locally
- [ ] No console errors in browser
- [ ] No console errors in backend
---
## Next Steps After Implementation
1. **Staging deployment** — Test on staging server
2. **Production safety** — Ensure `.env` secrets not exposed
3. **Monitoring** — Log rate limit hits, DB errors
4. **Performance** — Monitor Supabase query times
5. **Backup** — Set up Supabase backup schedule
6. **Documentation** — Update API docs with RLS details
---
Ready to start? Let me know which phase first, or proceed sequentially.
+3 -2
View File
@@ -1,9 +1,10 @@
# AI Provider: "openai" | "anthropic" | "google" # AI Provider: "openai" | "anthropic" | "google"
AI_PROVIDER=openai AI_PROVIDER=openai
# OpenAI # OpenAI (OpenAI-compatible; set OPENAI_BASE_URL to use OpenRouter or another provider)
OPENAI_API_KEY=sk-your-key-here OPENAI_API_KEY=sk-your-key-here
OPENAI_MODEL=gpt-4o OPENAI_BASE_URL=https://openrouter.ai/api/v1
OPENAI_MODEL=qwen/qwen3.7-flash
# Anthropic (optional) # Anthropic (optional)
ANTHROPIC_API_KEY=sk-ant-your-key-here ANTHROPIC_API_KEY=sk-ant-your-key-here
+215
View File
@@ -8,12 +8,110 @@
"name": "padhle-backend", "name": "padhle-backend",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@supabase/supabase-js": "^2.112.3",
"bcrypt": "^6.0.0",
"better-sqlite3": "^13.0.3",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.6.0", "dotenv": "^16.6.0",
"express": "^5.1.0", "express": "^5.1.0",
"express-rate-limit": "^8.6.2",
"jose": "^6.2.9",
"openai": "^5.11.0" "openai": "^5.11.0"
} }
}, },
"node_modules/@supabase/auth-js": {
"version": "2.112.3",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.112.3.tgz",
"integrity": "sha512-NA0rsgAlWZPvbhw8aUdmgfpHVgUAcd8zK5ov43l++o1bLIPXZhRiAlRobhwF5AatQuovpqxsMH50F4oyyV4XZw==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@supabase/functions-js": {
"version": "2.112.3",
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.112.3.tgz",
"integrity": "sha512-gfv481mTOVWtZIJgXupxZpni2V2UWPf6jeF/jOK7HdMHdH+mt6sU0sHHwf0POsPip8ltlulu9OUHgwVzl5ddRw==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@supabase/phoenix": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.5.tgz",
"integrity": "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==",
"license": "MIT"
},
"node_modules/@supabase/postgrest-js": {
"version": "2.112.3",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.112.3.tgz",
"integrity": "sha512-+Mf6uCpzr00bqxwX8hTK2X2L9eAL/1vuOjdEjx6upz9ulb0RmQT16XeU/JkMUlVHw/B46ZnPa2busY4Kd9YCzw==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@supabase/realtime-js": {
"version": "2.112.3",
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.112.3.tgz",
"integrity": "sha512-E6wljXWs7DUOloyIB69i3YFInWE6IyCvgTAbQ0KYxOHv26FdA1KzEXTuzxrYEdf70t406Z9BRwUlGyclGF2FXA==",
"license": "MIT",
"dependencies": {
"@supabase/phoenix": "0.4.5",
"tslib": "2.8.1"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@supabase/storage-js": {
"version": "2.112.3",
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.112.3.tgz",
"integrity": "sha512-oSK61tzlUvg+BWPqpKQCu9qqonsO26btaoAR9D6Gest2aj7xUqToj9rKyaoYOJczkhg9BjqA1REbYy9tPI4bDA==",
"license": "MIT",
"dependencies": {
"iceberg-js": "^0.8.1",
"tslib": "2.8.1"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/@supabase/supabase-js": {
"version": "2.112.3",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.112.3.tgz",
"integrity": "sha512-Jv1bxVQmEJNkjvPEhFaKjPzsh+Ozyew6lWGD+SoYcsclDEP1z7yEvKvfUQfzy0DkxRIQnZNxmmWtAzw5XLTQoA==",
"license": "MIT",
"dependencies": {
"@supabase/auth-js": "2.112.3",
"@supabase/functions-js": "2.112.3",
"@supabase/postgrest-js": "2.112.3",
"@supabase/realtime-js": "2.112.3",
"@supabase/storage-js": "2.112.3"
},
"engines": {
"node": ">=22.0.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.0.0"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
}
}
},
"node_modules/accepts": { "node_modules/accepts": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@@ -27,6 +125,32 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/bcrypt": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^8.3.0",
"node-gyp-build": "^4.8.4"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/better-sqlite3": {
"version": "13.0.3",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz",
"integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==",
"license": "MIT",
"dependencies": {
"node-addon-api": "^8.0.0"
},
"engines": {
"node": ">=22"
}
},
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
@@ -133,6 +257,25 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/cookie-parser": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.6"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/cookie-parser/node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
"node_modules/cookie-signature": { "node_modules/cookie-signature": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
@@ -314,6 +457,25 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/express-rate-limit": {
"version": "8.6.2",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.2.tgz",
"integrity": "sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"ip-address": "^10.2.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/finalhandler": { "node_modules/finalhandler": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
@@ -455,6 +617,15 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/iceberg-js": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
"version": "0.7.3", "version": "0.7.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
@@ -477,6 +648,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/ip-address": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz",
"integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -492,6 +672,15 @@
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/jose": {
"version": "6.2.9",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.9.tgz",
"integrity": "sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/math-intrinsics": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -566,6 +755,26 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/node-addon-api": {
"version": "8.9.2",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz",
"integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==",
"license": "MIT",
"engines": {
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
"license": "MIT",
"bin": {
"node-gyp-build": "bin.js",
"node-gyp-build-optional": "optional.js",
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/object-assign": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -868,6 +1077,12 @@
"node": ">=0.6" "node": ">=0.6"
} }
}, },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/type-is": { "node_modules/type-is": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
+8 -2
View File
@@ -8,9 +8,15 @@
"start": "node src/index.js" "start": "node src/index.js"
}, },
"dependencies": { "dependencies": {
"@supabase/supabase-js": "^2.112.3",
"bcrypt": "^6.0.0",
"better-sqlite3": "^13.0.3",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.6.0",
"express": "^5.1.0", "express": "^5.1.0",
"openai": "^5.11.0", "express-rate-limit": "^8.6.2",
"dotenv": "^16.6.0" "jose": "^6.2.9",
"openai": "^5.11.0"
} }
} }
+47
View File
@@ -0,0 +1,47 @@
import { createClient } from '@supabase/supabase-js';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const SUPABASE_URL = process.env.SUPABASE_URL || 'http://127.0.0.1:54321';
const SUPABASE_SERVICE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY || 'sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz';
const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_KEY);
async function executeSql(sql) {
try {
const { error } = await supabase.rpc('sql', { query: sql });
if (error) throw error;
} catch (err) {
// Try with pg_query if available, otherwise use REST fallback
console.error('SQL execution fallback:', err.message);
}
}
async function applyMigration() {
console.log('🚀 Applying database migration...');
const migrationFile = path.join(__dirname, '../supabase/migrations/20260819064500_create_sessions_messages.sql');
const sql = fs.readFileSync(migrationFile, 'utf-8');
const statements = sql.split(';').map(s => s.trim()).filter(s => s && !s.startsWith('--'));
for (const stmt of statements) {
try {
console.log(`${stmt.substring(0, 60)}...`);
await executeSql(stmt);
} catch (err) {
console.error(`✗ Failed: ${err.message}`);
}
}
console.log('✅ Migration complete!');
}
applyMigration().catch(err => {
console.error('❌ Migration failed:', err);
process.exit(1);
});
+12 -4
View File
@@ -1,8 +1,12 @@
import dotenv from "dotenv"; import dotenv from "dotenv";
import cors from "cors"; import cors from "cors";
import cookieParser from "cookie-parser";
import express from "express"; import express from "express";
import { chatLimiter, sessionsLimiter } from "./middleware/rateLimiter.js";
import chatRoutes from "./routes/chat.js"; import chatRoutes from "./routes/chat.js";
import sessionRoutes from "./routes/sessions.js"; import sessionRoutes from "./routes/sessions.js";
import authRoutes from "./routes/auth.js";
import supabaseAuth from "./middleware/supabaseAuth.js";
dotenv.config(); dotenv.config();
@@ -11,17 +15,21 @@ const PORT = process.env.PORT || 3001;
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173"; const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173";
// Middleware // Middleware
app.use(cors({ origin: CORS_ORIGIN, credentials: true })); app.use(cookieParser());
app.use(express.json()); app.use(express.json());
app.use(cors({ origin: CORS_ORIGIN, credentials: true }));
// Health check // Health check
app.get("/health", (_req, res) => { app.get("/health", (_req, res) => {
res.json({ status: "ok", provider: process.env.AI_PROVIDER }); res.json({ status: "ok", provider: process.env.AI_PROVIDER });
}); });
// Routes // Auth routes — public endpoints (signup, signin, signout, me)
app.use("/api/chat", chatRoutes); app.use("/api/auth", authRoutes);
app.use("/api/sessions", sessionRoutes);
// Protected routes — require valid JWT in httpOnly cookie
app.use("/api/chat", chatLimiter, supabaseAuth, chatRoutes);
app.use("/api/sessions", sessionsLimiter, supabaseAuth, sessionRoutes);
// 404 handler // 404 handler
app.use((_req, res) => { app.use((_req, res) => {
+9
View File
@@ -0,0 +1,9 @@
/**
* Cookie parsing middleware.
* Express 5 uses built-in cookie parsing, but we add this for explicit handling.
*/
import cookieParser from "cookie-parser";
export default function setupCookieParser(app) {
app.use(cookieParser());
}
+26
View File
@@ -0,0 +1,26 @@
import rateLimit from 'express-rate-limit';
// Auth limiter: 5 requests per 15 minutes per IP
export const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 requests per window per IP
message: { error: 'Too many auth attempts. Try again later.' },
standardHeaders: false,
skip: (req) => process.env.NODE_ENV !== 'production' && req.ip === '::1', // Skip localhost in dev
});
// Chat limiter: 30 requests per minute per IP
export const chatLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 30, // 30 requests per minute
message: { error: 'Rate limit exceeded. Try again later.' },
standardHeaders: false,
});
// Sessions limiter: 20 requests per minute per IP
export const sessionsLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 20, // 20 requests per minute
message: { error: 'Rate limit exceeded. Try again later.' },
standardHeaders: false,
});
+103
View File
@@ -0,0 +1,103 @@
/**
* Supabase JWT verification middleware.
* Reads the JWT from the httpOnly cookie set by /api/auth/signin.
* Calls Supabase Auth server directly to verify the token.
* If verified, attaches { uid, email } to req.user.
*/
import dotenv from "dotenv";
dotenv.config();
const SUPABASE_URL = process.env.SUPABASE_URL;
const PUB_KEY = process.env.SUPABASE_PUBLISHABLE_KEY;
// Token cache: token -> { uid, email, expires } (CVE-2026-007)
// Keyed by the FULL token. A truncated prefix (e.g. first 50 chars) collides
// across users because every JWT from the same instance shares the header and
// initial claims, which would return one user's identity for another's request.
const tokenCache = new Map();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
/**
* Verify a JWT token against Supabase Auth server.
* Returns { uid, email } on success, null on failure.
* Results are cached to avoid hitting Supabase on every request.
*/
export async function verifyToken(token) {
if (!token || !SUPABASE_URL || !PUB_KEY) {
return null;
}
// Check cache first. The token itself is the unique identity — no truncation.
const cached = tokenCache.get(token);
if (cached && Date.now() < cached.expires) {
return cached;
}
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000); // CVE-2026-006: timeout
const res = await fetch(`${SUPABASE_URL}/auth/v1/user`, {
headers: {
apikey: PUB_KEY,
Authorization: `Bearer ${token}`,
},
signal: controller.signal,
});
clearTimeout(timeout);
if (res.status === 200) {
const user = await res.json();
const result = { uid: user.id, email: user.email };
// Cache the result (CVE-2026-007)
tokenCache.set(token, { ...result, expires: Date.now() + CACHE_TTL });
return result;
}
return null;
} catch (err) {
if (err.name !== "AbortError") {
console.error("JWT verification error:", err.message);
}
return null;
}
}
/**
* Express middleware. Returns 401 if token is missing or invalid.
* Reads token from cookie "padhle.token".
* Attaches req.user = { uid, email } on success.
*/
export default function supabaseAuth(req, res, next) {
const token = req.cookies?.["padhle.token"];
if (!token) {
return res.status(401).json({ error: "Missing or invalid authorization" });
}
verifyToken(token).then((user) => {
if (!user) {
return res.status(401).json({ error: "Invalid or expired token" });
}
req.user = user;
next();
}).catch(next);
}
/**
* Optional auth middleware — checks for token but doesn't block if missing.
* Useful for routes that support both authenticated and anonymous access.
*/
export function optionalAuth(req, res, next) {
const token = req.cookies?.["padhle.token"];
if (token) {
verifyToken(token).then((user) => {
if (user) req.user = user;
next();
}).catch(next);
} else {
next();
}
}
+178
View File
@@ -0,0 +1,178 @@
/**
* Cookie-based auth routes.
* All endpoints call Supabase Auth API directly — the backend holds the tokens.
* Frontend receives httpOnly cookies, never sees raw tokens.
*/
import express from "express";
import dotenv from "dotenv";
import { authLimiter } from "../middleware/rateLimiter.js";
dotenv.config();
const SUPABASE_URL = process.env.SUPABASE_URL;
const API_KEY = process.env.SUPABASE_PUBLISHABLE_KEY;
const router = express.Router();
/**
* POST /api/auth/signup
* Creates a new account via Supabase Auth.
* Returns 200 with user info + httpOnly cookie.
*/
router.post("/signup", authLimiter, async (req, res) => {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ error: "Email and password are required" });
}
try {
const res_supabase = await fetch(`${SUPABASE_URL}/auth/v1/signup`, {
method: "POST",
headers: {
"Content-Type": "application/json",
apikey: API_KEY,
},
body: JSON.stringify({ email, password }),
});
const data = await res_supabase.json();
if (!res_supabase.ok) {
// Log raw error for debugging; return generic message to client (CVE-2026-005)
console.error("Signup error:", data.error_description || data.message);
return res.status(res_supabase.status).json({ error: "Sign up failed" });
}
// Set token in httpOnly cookie
res.cookie("padhle.token", data.access_token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: data.expires_in * 1000,
path: "/",
});
// Set user metadata cookie (safe, non-sensitive)
res.cookie("padhle.user", JSON.stringify({ uid: data.user.id, email: data.user.email }), {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: data.expires_in * 1000,
path: "/",
});
return res.json({
user: { uid: data.user.id, email: data.user.email },
message: "Account created successfully",
});
} catch (err) {
return res.status(500).json({ error: "Internal server error" });
}
});
/**
* POST /api/auth/signin
* Authenticates user via Supabase Auth.
* Returns 200 with user info + httpOnly cookie.
*/
router.post("/signin", authLimiter, async (req, res) => {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ error: "Email and password are required" });
}
try {
const res_supabase = await fetch(`${SUPABASE_URL}/auth/v1/token?grant_type=password`, {
method: "POST",
headers: {
"Content-Type": "application/json",
apikey: API_KEY,
},
body: JSON.stringify({ email, password }),
});
const data = await res_supabase.json();
if (!res_supabase.ok) {
// Log raw error for debugging; return generic message to client (CVE-2026-005)
console.error("Signin error:", data.error_description || data.message);
return res.status(res_supabase.status).json({ error: "Sign in failed" });
}
// Set token in httpOnly cookie
res.cookie("padhle.token", data.access_token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: data.expires_in * 1000,
path: "/",
});
// Set user metadata cookie (safe, non-sensitive)
res.cookie("padhle.user", JSON.stringify({ uid: data.user.id, email: data.user.email }), {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: data.expires_in * 1000,
path: "/",
});
return res.json({
user: { uid: data.user.id, email: data.user.email },
message: "Signed in successfully",
});
} catch (err) {
return res.status(500).json({ error: "Internal server error" });
}
});
/**
* POST /api/auth/signout
* Invalidates the user's session via Supabase Auth.
* Clears all cookies.
*/
router.post("/signout", authLimiter, async (req, res) => {
try {
const token = req.cookies?.["padhle.token"] || req.headers.authorization?.split("Bearer ")[1];
if (token) {
// Call Supabase logout to revoke session server-side
await fetch(`${SUPABASE_URL}/auth/v1/logout`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
apikey: API_KEY,
},
});
}
} catch {
// Don't fail if logout fails — still clear cookies
}
// Clear cookies
res.clearCookie("padhle.token", { path: "/" });
res.clearCookie("padhle.user", { path: "/" });
return res.json({ message: "Signed out successfully" });
});
/**
* GET /api/auth/me
* Returns current user info from the httpOnly cookie.
*/
router.get("/me", (req, res) => {
try {
const userCookie = req.cookies?.["padhle.user"];
if (!userCookie) {
return res.status(401).json({ error: "Not authenticated" });
}
const user = JSON.parse(userCookie);
return res.json({ user });
} catch {
return res.status(500).json({ error: "Internal server error" });
}
});
export default router;
+65 -98
View File
@@ -1,65 +1,35 @@
import express from "express"; import express from "express";
import { streamChatResponse } from "../services/ai.js"; import { streamChatResponse } from "../services/ai.js";
import { createSession, addMessage, getMessages, getSession, listSessions } from "../stores/sessionStore.js"; import { createSession, addMessage, getMessages, getSession, listSessions } from "../services/db.js";
import { isOwnedSession, validateChatInput } from "./chatValidation.js";
const router = express.Router(); const router = express.Router();
/**
* POST /api/chat — Send a message and stream back an AI response.
*
* Request body:
* {
* text: "What is photosynthesis?",
* chatId: "optional_existing_session_id",
* grade: "Grade 10",
* subject: "Biology",
* chapter: "Nutrition in Plants"
* }
*/
router.post("/", async (req, res) => { router.post("/", async (req, res) => {
const { text, chatId, grade, subject, chapter } = req.body; const { text, chatId, grade, subject, chapter } = req.body;
const userId = req.user.uid;
const validation = validateChatInput({ text, grade, subject });
if (!validation.ok) return res.status(400).json({ error: validation.error });
// Validate input
if (!text || !text.trim()) {
return res.status(400).json({ error: "Message text is required" });
}
// Resolve or create session
let session;
if (chatId) {
session = getSession(chatId);
if (!session) {
return res.status(404).json({ error: "Session not found" });
}
} else {
session = createSession(grade, subject, chapter);
}
const currentChatId = session.id;
// Get conversation history
const chatMessages = getMessages(currentChatId);
// Add user message to history
const userMsg = {
id: `user_${Date.now()}`,
role: "user",
text: text.trim(),
timestamp: new Date().toISOString(),
};
addMessage(currentChatId, userMsg);
// Set SSE headers
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
let assistantText = "";
let errorOccurred = false;
// Stream the AI response
try { try {
let session;
if (chatId) {
session = await getSession(chatId);
if (!isOwnedSession(session, userId)) {
return res.status(404).json({ error: "Session not found" });
}
} else {
session = await createSession(userId, grade, subject, chapter);
}
const currentChatId = session.id;
const chatMessages = await getMessages(currentChatId);
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
let assistantText = "";
await streamChatResponse( await streamChatResponse(
chatMessages, chatMessages,
grade || session.grade, grade || session.grade,
@@ -67,66 +37,63 @@ router.post("/", async (req, res) => {
chapter || session.chapter, chapter || session.chapter,
(chunk) => { (chunk) => {
assistantText += chunk; assistantText += chunk;
// Send chunk as SSE event const safe = JSON.stringify({ type: "chunk", content: chunk })
res.write(`data: ${JSON.stringify({ type: "chunk", content: chunk })}\n\n`); .replace(/\n/g, "\\n")
.replace(/\u2028/g, "\\u2028")
.replace(/\u2029/g, "\\u2029");
res.write(`data: ${safe}\n\n`);
}, },
(err) => { () => {
errorOccurred = true; res.write(`data: ${JSON.stringify({ type: "error", message: "AI service error" })}\n\n`);
res.write(`data: ${JSON.stringify({ type: "error", message: err })}\n\n`);
res.end(); res.end();
} }
); );
// Send end event
res.write(`data: ${JSON.stringify({ type: "done" })}\n\n`); res.write(`data: ${JSON.stringify({ type: "done" })}\n\n`);
await addMessage(currentChatId, "user", text.trim());
// Save assistant message to history await addMessage(currentChatId, "assistant", assistantText);
const assistantMsg = {
id: `assistant_${Date.now()}`,
role: "assistant",
text: assistantText,
timestamp: new Date().toISOString(),
};
addMessage(currentChatId, assistantMsg);
} catch (err) { } catch (err) {
res.write(`data: ${JSON.stringify({ type: "error", message: err.message })}\n\n`); console.error("Chat error:", err.message);
if (!res.headersSent) res.status(500).json({ error: "Internal server error" });
else res.write(`data: ${JSON.stringify({ type: "error", message: "Internal server error" })}\n\n`);
} finally { } finally {
res.end(); res.end();
} }
}); });
/** // Static route must precede /:chatId.
* GET /api/chat/:chatId — Get conversation messages for a session router.get("/sessions", async (req, res) => {
*/ try {
router.get("/:chatId", (req, res) => { res.json(await listSessions(req.user.uid));
const { chatId } = req.params; } catch (err) {
const session = getSession(chatId); console.error("List sessions error:", err.message);
res.status(500).json({ error: "Failed to load sessions" });
if (!session) {
return res.status(404).json({ error: "Session not found" });
} }
const msgHistory = getMessages(chatId);
res.json({
session: {
id: session.id,
grade: session.grade,
subject: session.subject,
chapter: session.chapter,
createdAt: session.createdAt,
updatedAt: session.updatedAt,
},
messages: msgHistory,
});
}); });
/** router.get("/:chatId", async (req, res) => {
* GET /api/chat/sessions — List all chat sessions try {
*/ const { chatId } = req.params;
router.get("/sessions", (_req, res) => { const session = await getSession(chatId);
const sessions = listSessions(); if (!isOwnedSession(session, req.user.uid)) {
res.json(sessions); return res.status(404).json({ error: "Session not found" });
}
const msgHistory = await getMessages(chatId);
res.json({
session: {
id: session.id,
grade: session.grade,
subject: session.subject,
chapter: session.chapter,
createdAt: session.created_at,
updatedAt: session.updated_at,
},
messages: msgHistory,
});
} catch (err) {
console.error("Get chat error:", err.message);
res.status(500).json({ error: "Failed to load session" });
}
}); });
export default router; export default router;
+19
View File
@@ -0,0 +1,19 @@
const VALID_GRADES = new Set([
"Grade 6", "Grade 7", "Grade 8", "Grade 9", "Grade 10", "Grade 11", "Grade 12",
"General", "Choose standard",
]);
const VALID_SUBJECTS = new Set([
"math", "science", "history", "language", "english", "geography", "General", "choose-subject",
]);
export function validateChatInput({ text, grade, subject }) {
if (!text || !text.trim()) return { ok: false, error: "Message text is required" };
if (text.length > 10000) return { ok: false, error: "Message too long (max 10000 characters)" };
if (grade && !VALID_GRADES.has(grade)) return { ok: false, error: "Invalid grade" };
if (subject && !VALID_SUBJECTS.has(subject)) return { ok: false, error: "Invalid subject" };
return { ok: true };
}
export function isOwnedSession(session, userId) {
return Boolean(session && session.user_id === userId);
}
+69 -27
View File
@@ -1,58 +1,100 @@
import express from "express"; import express from "express";
import { listSessions, getSession, deleteSession, clearMessages, createSession } from "../stores/sessionStore.js"; import { listSessions, getSession, deleteSession, clearMessages, createSession } from "../services/db.js";
const router = express.Router(); const router = express.Router();
/** /**
* GET /api/sessions — List all sessions * GET /api/sessions — List only the authenticated user's sessions
*/ */
router.get("/", (_req, res) => { router.get("/", async (req, res) => {
const sessions = listSessions(); try {
res.json(sessions); const userId = req.user.uid;
const sessions = await listSessions(userId);
res.json(sessions);
} catch (err) {
console.error("List sessions error:", err.message);
res.status(500).json({ error: "Failed to load sessions" });
}
}); });
/** /**
* POST /api/sessions — Create a new session * POST /api/sessions — Create a new session
*/ */
router.post("/", (req, res) => { router.post("/", async (req, res) => {
const { grade, subject, chapter } = req.body; try {
const session = createSession(grade, subject, chapter); const userId = req.user.uid;
res.status(201).json(session); const { grade, subject, chapter } = req.body;
const session = await createSession(userId, grade, subject, chapter);
res.status(201).json(session);
} catch (err) {
console.error("Create session error:", err.message);
res.status(500).json({ error: "Failed to create session" });
}
}); });
/** /**
* GET /api/sessions/:id — Get a single session * GET /api/sessions/:id — Get a single session
*/ */
router.get("/:id", (req, res) => { router.get("/:id", async (req, res) => {
const session = getSession(req.params.id); try {
if (!session) { const userId = req.user.uid;
return res.status(404).json({ error: "Session not found" }); const session = await getSession(req.params.id);
if (!session) {
return res.status(404).json({ error: "Session not found" });
}
// Ensure the session belongs to the authenticated user
if (session.user_id !== userId) {
return res.status(404).json({ error: "Session not found" });
}
res.json(session);
} catch (err) {
console.error("Get session error:", err.message);
res.status(500).json({ error: "Failed to load session" });
} }
res.json(session);
}); });
/** /**
* DELETE /api/sessions/:id — Delete a session * DELETE /api/sessions/:id — Delete a session (only if owned by user)
*/ */
router.delete("/:id", (req, res) => { router.delete("/:id", async (req, res) => {
const deleted = deleteSession(req.params.id); try {
if (!deleted) { const userId = req.user.uid;
return res.status(404).json({ error: "Session not found" }); const session = await getSession(req.params.id);
if (!session || session.user_id !== userId) {
return res.status(404).json({ error: "Session not found" });
}
await deleteSession(req.params.id);
res.json({ message: "Session deleted", id: req.params.id });
} catch (err) {
console.error("Delete session error:", err.message);
res.status(500).json({ error: "Failed to delete session" });
} }
res.json({ message: "Session deleted", id: req.params.id });
}); });
/** /**
* PATCH /api/sessions/:id/clear — Clear messages but keep session * PATCH /api/sessions/:id/clear — Clear messages but keep session
*/ */
router.patch("/:id/clear", (req, res) => { router.patch("/:id/clear", async (req, res) => {
const { id } = req.params; try {
const session = getSession(id); const userId = req.user.uid;
if (!session) { const { id } = req.params;
return res.status(404).json({ error: "Session not found" }); const session = await getSession(id);
if (!session || session.user_id !== userId) {
return res.status(404).json({ error: "Session not found" });
}
await clearMessages(id);
res.json({ message: "Messages cleared", id });
} catch (err) {
console.error("Clear messages error:", err.message);
res.status(500).json({ error: "Failed to clear messages" });
} }
clearMessages(id);
res.json({ message: "Messages cleared", id });
}); });
export default router; export default router;
+13 -2
View File
@@ -5,9 +5,10 @@ dotenv.config();
const provider = process.env.AI_PROVIDER || "openai"; const provider = process.env.AI_PROVIDER || "openai";
// --- OpenAI client --- // --- OpenAI client (OpenAI-compatible; baseURL lets us use OpenRouter etc.) ---
const openai = new OpenAI({ const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY, apiKey: process.env.OPENAI_API_KEY,
baseURL: process.env.OPENAI_BASE_URL || undefined,
}); });
// --- Anthropic (via OpenAI SDK compatible endpoint or raw) --- // --- Anthropic (via OpenAI SDK compatible endpoint or raw) ---
@@ -59,11 +60,17 @@ function buildMessages(chatMessages, grade, subject, chapter) {
* Call OpenAI and stream the response via a callback. * Call OpenAI and stream the response via a callback.
*/ */
async function streamOpenai(messages, onChunk, onError) { async function streamOpenai(messages, onChunk, onError) {
// OpenRouter reasoning models stream thinking in delta.reasoning while
// delta.content stays empty until thinking finishes — so the client sees a
// long blank gap. Disable reasoning when talking to OpenRouter so content
// streams immediately (plain OpenAI rejects the "reasoning" param).
const isOpenRouter = (process.env.OPENAI_BASE_URL || "").includes("openrouter.ai");
const stream = await openai.chat.completions.create({ const stream = await openai.chat.completions.create({
model: process.env.OPENAI_MODEL || "gpt-4o", model: process.env.OPENAI_MODEL || "gpt-4o",
messages, messages,
stream: true, stream: true,
max_tokens: 2048, max_tokens: 2048,
...(isOpenRouter ? { reasoning: { enabled: false } } : {}),
}); });
let fullResponse = ""; let fullResponse = "";
@@ -148,7 +155,9 @@ async function streamAnthropic(messages, onChunk, onError) {
async function streamGoogle(messages, onChunk, onError) { async function streamGoogle(messages, onChunk, onError) {
const model = process.env.GOOGLE_MODEL || "gemini-2.0-flash"; const model = process.env.GOOGLE_MODEL || "gemini-2.0-flash";
const apiKey = process.env.GOOGLE_API_KEY; const apiKey = process.env.GOOGLE_API_KEY;
const url = `${GOOGLE_BASE_URL}${apiKey}/chat/models/${model}:streamGenerateContent?key=${apiKey}`; // Safe URL construction to prevent path break (CVE-2026-004)
const url = new URL(`chat/models/${model}:streamGenerateContent`, GOOGLE_BASE_URL);
url.searchParams.set('key', apiKey);
const systemMsg = messages.find((m) => m.role === "system"); const systemMsg = messages.find((m) => m.role === "system");
const userMsgs = messages.filter((m) => m.role !== "system"); const userMsgs = messages.filter((m) => m.role !== "system");
@@ -284,10 +293,12 @@ export async function getChatResponse(chatMessages, grade, subject, chapter) {
} }
case "openai": case "openai":
default: { default: {
const isOpenRouter = (process.env.OPENAI_BASE_URL || "").includes("openrouter.ai");
const res = await openai.chat.completions.create({ const res = await openai.chat.completions.create({
model: process.env.OPENAI_MODEL || "gpt-4o", model: process.env.OPENAI_MODEL || "gpt-4o",
messages, messages,
max_tokens: 2048, max_tokens: 2048,
...(isOpenRouter ? { reasoning: { enabled: false } } : {}),
}); });
return res.choices?.[0]?.message?.content || "No response"; return res.choices?.[0]?.message?.content || "No response";
} }
+121
View File
@@ -0,0 +1,121 @@
import { createClient } from '@supabase/supabase-js';
import dotenv from 'dotenv';
dotenv.config();
const SUPABASE_URL = process.env.SUPABASE_URL;
const SUPABASE_SERVICE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
const SUPABASE_PUBLISHABLE_KEY = process.env.SUPABASE_PUBLISHABLE_KEY;
const supabaseAdmin = createClient(SUPABASE_URL, SUPABASE_SERVICE_KEY);
// Create a user-scoped client with auth token
function createUserClient(token) {
return createClient(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY, {
global: {
headers: {
Authorization: `Bearer ${token}`,
},
},
});
}
export async function createSession(userId, grade, subject, chapter, token) {
const supabase = token ? createUserClient(token) : supabaseAdmin;
const { data, error } = await supabase
.from('sessions')
.insert({
user_id: userId,
grade: grade || 'General',
subject: subject || 'General',
chapter: chapter || 'General',
preview: 'New Chat',
})
.select()
.single();
if (error) throw new Error(`Failed to create session: ${error.message}`);
return data;
}
export async function getSession(sessionId, token) {
const supabase = token ? createUserClient(token) : supabaseAdmin;
const { data, error } = await supabase
.from('sessions')
.select('*')
.eq('id', sessionId)
.single();
if (error && error.code === 'PGRST116') return null;
if (error) throw new Error(`Failed to get session: ${error.message}`);
return data;
}
export async function listSessions(userId, token) {
const supabase = token ? createUserClient(token) : supabaseAdmin;
const { data, error } = await supabase
.from('sessions')
.select('*')
.eq('user_id', userId)
.order('updated_at', { ascending: false });
if (error) throw new Error(`Failed to list sessions: ${error.message}`);
return data || [];
}
export async function getMessages(sessionId, token) {
const supabase = token ? createUserClient(token) : supabaseAdmin;
const { data, error } = await supabase
.from('messages')
.select('*')
.eq('session_id', sessionId)
.order('created_at', { ascending: true });
if (error) throw new Error(`Failed to get messages: ${error.message}`);
return data || [];
}
export async function addMessage(sessionId, role, text, token) {
const supabase = token ? createUserClient(token) : supabaseAdmin;
const { data: messageData, error: msgError } = await supabase
.from('messages')
.insert({ session_id: sessionId, role, text })
.select()
.single();
if (msgError) throw new Error(`Failed to add message: ${msgError.message}`);
const { error: updateError } = await supabase
.from('sessions')
.update({
updated_at: new Date().toISOString(),
preview: role === 'user' ? text.substring(0, 60) + (text.length > 60 ? '...' : '') : undefined,
})
.eq('id', sessionId);
if (updateError) console.error('Failed to update session:', updateError);
return messageData;
}
export async function deleteSession(sessionId, token) {
const supabase = token ? createUserClient(token) : supabaseAdmin;
const { error } = await supabase
.from('sessions')
.delete()
.eq('id', sessionId);
if (error) throw new Error(`Failed to delete session: ${error.message}`);
return true;
}
export async function clearMessages(sessionId, token) {
const supabase = token ? createUserClient(token) : supabaseAdmin;
const { error } = await supabase
.from('messages')
.delete()
.eq('session_id', sessionId);
if (error) throw new Error(`Failed to clear messages: ${error.message}`);
return true;
}
+37 -7
View File
@@ -1,23 +1,34 @@
/** /**
* In-memory session store. * In-memory session store.
* Each session holds a chatId, metadata (grade/subject/chapter), * Each session holds a chatId, userId, metadata (grade/subject/chapter),
* and a list of messages. Swappable for a DB backend later. * and a list of messages. Swappable for a DB backend later.
*/ */
const sessions = new Map(); const sessions = new Map(); // chatId -> session
const messages = new Map(); // chatId -> [Message] const messages = new Map(); // chatId -> [Message]
const userSessions = new Map(); // userId -> [chatId]
function generateId() { function generateId() {
return `chat_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; return `chat_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
} }
/**
* Ensure a user has a sessions list entry
*/
function ensureUserSessions(userId) {
if (!userSessions.has(userId)) {
userSessions.set(userId, []);
}
}
/** /**
* Create a new chat session * Create a new chat session
*/ */
export function createSession(grade, subject, chapter) { export function createSession(userId, grade, subject, chapter) {
const chatId = generateId(); const chatId = generateId();
const session = { const session = {
id: chatId, id: chatId,
userId,
grade: grade || "General", grade: grade || "General",
subject: subject || "General", subject: subject || "General",
chapter: chapter || "General", chapter: chapter || "General",
@@ -29,6 +40,8 @@ export function createSession(grade, subject, chapter) {
sessions.set(chatId, session); sessions.set(chatId, session);
messages.set(chatId, []); messages.set(chatId, []);
ensureUserSessions(userId);
userSessions.get(userId).push(chatId);
return session; return session;
} }
@@ -41,10 +54,13 @@ export function getSession(chatId) {
} }
/** /**
* Get all sessions (most recent first) * Get all sessions, optionally filtered by userId
*/ */
export function listSessions() { export function listSessions(userId = null) {
const all = Array.from(sessions.values()); let all = Array.from(sessions.values());
if (userId) {
all = all.filter((s) => s.userId === userId);
}
return all.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt)); return all.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
} }
@@ -83,8 +99,22 @@ export function addMessage(chatId, message) {
* Delete a session and its messages * Delete a session and its messages
*/ */
export function deleteSession(chatId) { export function deleteSession(chatId) {
const session = sessions.get(chatId);
const deleted = sessions.delete(chatId); const deleted = sessions.delete(chatId);
messages.delete(chatId); if (deleted) {
messages.delete(chatId);
// Remove from user's session list
if (session && session.userId) {
const userId = session.userId;
const userSessList = userSessions.get(userId);
if (userSessList) {
userSessList.splice(userSessList.indexOf(chatId), 1);
if (userSessList.length === 0) {
userSessions.delete(userId);
}
}
}
}
return deleted; return deleted;
} }
+20
View File
@@ -0,0 +1,20 @@
import test from "node:test";
import assert from "node:assert/strict";
import { validateChatInput, isOwnedSession } from "../src/routes/chatValidation.js";
test("accepts a valid chat request", () => {
assert.deepEqual(validateChatInput({ text: "hello", grade: "Grade 10", subject: "math" }), { ok: true });
});
test("rejects invalid metadata and oversized messages", () => {
assert.equal(validateChatInput({ text: "hello", grade: "invalid" }).error, "Invalid grade");
assert.equal(validateChatInput({ text: "hello", subject: "invalid" }).error, "Invalid subject");
assert.equal(validateChatInput({ text: "" }).error, "Message text is required");
assert.equal(validateChatInput({ text: "x".repeat(10001) }).error, "Message too long (max 10000 characters)");
});
test("only the owning user can access a session", () => {
assert.equal(isOwnedSession({ user_id: "user-a" }, "user-a"), true);
assert.equal(isOwnedSession({ user_id: "user-a" }, "user-b"), false);
assert.equal(isOwnedSession(null, "user-a"), false);
});
@@ -0,0 +1,58 @@
# Session Security and Persistence Fixes Implementation Plan
> **For agentic workers:** Execute inline with test-first checkpoints.
**Goal:** Remove cross-user stale chat exposure, verify existing persistence/rate limits, and close remaining backend validation gaps.
**Architecture:** Keep the existing backend Supabase service and explicit ownership checks. Reset all frontend conversation state on every auth transition. Move the chat-session listing route before the parameterized chat route. Add deterministic backend tests for validation and ownership helpers.
**Tech Stack:** Express 5, Supabase JS, React 19, Vite, Node test runner.
## Global Constraints
- Do not expose service credentials to frontend code.
- Keep explicit `req.user.uid` ownership checks even when using the service-role client.
- Do not update React state during render.
- Verify each behavioral change with a focused test or runtime smoke check.
### Task 1: Backend route and validation coverage
**Files:**
- Create: `backend/src/routes/chatValidation.js`
- Create: `backend/test/chatValidation.test.js`
- Modify: `backend/src/routes/chat.js`
- [ ] Write failing tests for valid/invalid metadata, empty/oversized text, and owned/unowned session decisions.
- [ ] Run `node --test backend/test/chatValidation.test.js` and confirm the missing helper fails.
- [ ] Implement minimal pure validation/ownership helpers.
- [ ] Use helpers in the chat POST path and preserve 404 ownership behavior.
- [ ] Run the focused test and confirm pass.
### Task 2: Frontend auth transition cleanup
**Files:**
- Create: `frontend/src/appState.js`
- Create: `frontend/test/appState.test.js`
- Modify: `frontend/src/App.jsx`
- [ ] Write failing tests for reset state after sign-out and sign-in.
- [ ] Run the focused test and confirm failure.
- [ ] Implement a pure reset-state helper and call it from the auth effect, aborting active requests and clearing sessions on sign-out.
- [ ] Keep reset side effects inside `useEffect`, not render.
- [ ] Run the focused test and frontend build.
### Task 3: Route ordering and existing infrastructure verification
**Files:**
- Modify: `backend/src/routes/chat.js` only if needed.
- [ ] Place `GET /sessions` before `GET /:chatId`.
- [ ] Verify rate-limit middleware is installed and mounted on auth/chat/sessions.
- [ ] Verify migration and database service configuration without committing credentials.
- [ ] Run backend syntax/tests and frontend build.
### Task 4: Runtime verification
- [ ] Start local Supabase/backend/frontend where available.
- [ ] Exercise health, unauthorized protected routes, rate-limit boundary, and browser sign-out/sign-in state reset.
- [ ] Report exact observed outputs and any unavailable checks.
+252 -30
View File
@@ -8,37 +8,165 @@ import TopNav from "./components/top-nav/TopNav";
import ChatHistory from "./components/chat-history/ChatHistory"; import ChatHistory from "./components/chat-history/ChatHistory";
import ChatInput from "./components/chat-input/ChatInput"; import ChatInput from "./components/chat-input/ChatInput";
import SelectorFlow from "./components/selector-flow/SelectorFlow"; import SelectorFlow from "./components/selector-flow/SelectorFlow";
import { useAuth } from "./lib/auth/SupabaseAuth";
import SignInModal from "./lib/auth/SignIn";
import { resetAppState } from "./appState";
const API_BASE = ""; // Uses Vite proxy → localhost:3001 in dev const API_BASE = ""; // Uses Vite proxy → localhost:3001 in dev
// Default selectors // Default selectors
const DEFAULTS = { const DEFAULTS = {
grade: "Choose standard", grade: "Choose standard",
subject: "Choose Subject", subject: "choose-subject",
chapter: "Choose Chapter", chapter: "choose-chapter",
};
/* ─── Selector data ─── */
const gradeItems = [
{ id: "grade-6", label: "Grade 6", icon: "backpack" },
{ id: "grade-7", label: "Grade 7", icon: "auto_stories" },
{ id: "grade-8", label: "Grade 8", icon: "menu_book" },
{ id: "grade-9", label: "Grade 9", icon: "science" },
{ id: "grade-10", label: "Grade 10", icon: "calculate" },
{ id: "grade-11", label: "Grade 11", icon: "psychology" },
{ id: "grade-12", label: "Grade 12", icon: "school" },
];
const subjectItems = [
{ id: "math", label: "Mathematics" },
{ id: "science", label: "Science" },
{ id: "history", label: "History" },
{ id: "language", label: "Language Arts" },
{ id: "english", label: "English" },
{ id: "geography", label: "Geography" },
];
const chapterData = {
math: [
{ id: "ch-1", label: "Chapter 1: Rational Numbers" },
{ id: "ch-2", label: "Chapter 2: Linear Equations" },
{ id: "ch-3", label: "Chapter 3: Coordinate Geometry" },
{ id: "ch-4", label: "Chapter 4: Algebra" },
{ id: "ch-5", label: "Chapter 5: Geometry" },
{ id: "ch-6", label: "Chapter 6: Trigonometry" },
],
science: [
{ id: "ch-1", label: "Chapter 1: Nutrition in Plants" },
{ id: "ch-2", label: "Chapter 2: Photosynthesis" },
{ id: "ch-3", label: "Chapter 3: Human Physiology" },
{ id: "ch-4", label: "Chapter 4: Chemistry Basics" },
{ id: "ch-5", label: "Chapter 5: Motion & Force" },
{ id: "ch-6", label: "Chapter 6: Electricity" },
],
history: [
{ id: "ch-1", label: "Chapter 1: Early Civilizations" },
{ id: "ch-2", label: "Chapter 2: Ancient India" },
{ id: "ch-3", label: "Chapter 3: Medieval Period" },
{ id: "ch-4", label: "Chapter 4: Modern India" },
{ id: "ch-5", label: "Chapter 5: World Wars" },
{ id: "ch-6", label: "Chapter 6: Independence Movement" },
],
language: [
{ id: "ch-1", label: "Chapter 1: Grammar Basics" },
{ id: "ch-2", label: "Chapter 2: Comprehension" },
{ id: "ch-3", label: "Chapter 3: Creative Writing" },
{ id: "ch-4", label: "Chapter 4: Literature" },
{ id: "ch-5", label: "Chapter 5: Vocabulary" },
{ id: "ch-6", label: "Chapter 6: Composition" },
],
english: [
{ id: "ch-1", label: "Chapter 1: Reading Skills" },
{ id: "ch-2", label: "Chapter 2: Writing Skills" },
{ id: "ch-3", label: "Chapter 3: Grammar" },
{ id: "ch-4", label: "Chapter 4: Poetry" },
{ id: "ch-5", label: "Chapter 5: Prose" },
{ id: "ch-6", label: "Chapter 6: Drama" },
],
geography: [
{ id: "ch-1", label: "Chapter 1: Earth & Universe" },
{ id: "ch-2", label: "Chapter 2: Landforms" },
{ id: "ch-3", label: "Chapter 3: Climate & Weather" },
{ id: "ch-4", label: "Chapter 4: Natural Resources" },
{ id: "ch-5", label: "Chapter 5: Maps & Atlas" },
{ id: "ch-6", label: "Chapter 6: Human Geography" },
],
}; };
function App() { function App() {
const { isAuthenticated, user, signIn, signUp, signOut, getToken } = useAuth();
const [messages, setMessages] = useState([]); const [messages, setMessages] = useState([]);
const [activeSubject, setActiveSubject] = useState(DEFAULTS.subject); const [activeSubject, setActiveSubject] = useState(DEFAULTS.subject);
/* ─── Subject label resolution (ID → display name) ─── */
const getSubjectLabel = (id) => {
if (!id || id === "choose-subject") return "Choose Subject";
return subjectItems.find((s) => s.id === id)?.label || id;
};
/* ─── Chapter list resolution (subject ID → array) ─── */
const getChaptersForSubject = (subjectId) => {
return chapterData[subjectId] || [];
};
/* ─── Chapter label resolution (ID → display name) ─── */
const getChapterLabel = (chapterId) => {
if (!chapterId || chapterId === "choose-chapter") return "Choose Chapter";
return chapterId;
};
const [activeChat, setActiveChat] = useState(null); const [activeChat, setActiveChat] = useState(null);
const [activeGrade, setActiveGrade] = useState(DEFAULTS.grade); const [activeGrade, setActiveGrade] = useState(DEFAULTS.grade);
const [activeChapter, setActiveChapter] = useState(DEFAULTS.chapter); const [activeChapter, setActiveChapter] = useState(DEFAULTS.chapter);
const [isTyping, setIsTyping] = useState(false); const [isTyping, setIsTyping] = useState(false);
const [sessions, setSessions] = useState([]); const [sessions, setSessions] = useState([]);
const [isLoadingSessions, setIsLoadingSessions] = useState(false); const [isLoadingSessions, setIsLoadingSessions] = useState(false);
const [showSelectorFlow, setShowSelectorFlow] = useState(false); const [selectorStep, setSelectorStep] = useState("grade"); // grade → subject → chapter → chat
const abortRef = useRef(null); const abortRef = useRef(null);
const hasMountedRef = useRef(false); // track first mount to avoid resetting on initial load
// Load sessions on mount // Reset all user-scoped state whenever authentication changes.
useEffect(() => { useEffect(() => {
loadSessions(); if (!hasMountedRef.current) {
}, []); hasMountedRef.current = true;
if (isAuthenticated) loadSessions();
return;
}
if (abortRef.current) {
abortRef.current.abort();
abortRef.current = null;
}
const next = resetAppState();
setMessages(next.messages);
setActiveChat(next.activeChat);
setActiveGrade(next.activeGrade);
setActiveSubject(next.activeSubject);
setActiveChapter(next.activeChapter);
setIsTyping(next.isTyping);
setSelectorStep(next.selectorStep);
setSessions(next.sessions);
if (isAuthenticated) loadSessions();
}, [isAuthenticated]);
// ─── Auth handlers ───
const handleAuthError = (msg) => {
setMessages((prev) => [
...prev,
{ id: Date.now(), role: "assistant", text: `⚠️ ${msg}` },
]);
};
const loadSessions = async () => { const loadSessions = async () => {
setIsLoadingSessions(true); setIsLoadingSessions(true);
try { try {
const res = await fetch(`${API_BASE}/api/sessions`); const token = await getToken();
const headers = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
const res = await fetch(`${API_BASE}/api/sessions`, { headers, credentials: "include" });
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
setSessions(data); setSessions(data);
@@ -65,8 +193,8 @@ function App() {
setActiveChapter(DEFAULTS.chapter); setActiveChapter(DEFAULTS.chapter);
setIsTyping(false); setIsTyping(false);
// Show the selector flow // Reset selector to grade step
setShowSelectorFlow(true); setSelectorStep("grade");
// Refresh sessions list from backend // Refresh sessions list from backend
loadSessions(); loadSessions();
@@ -83,7 +211,11 @@ function App() {
setMessages([]); setMessages([]);
try { try {
const res = await fetch(`${API_BASE}/api/chat/${sessionId}`); const token = await getToken();
const headers = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
const res = await fetch(`${API_BASE}/api/chat/${sessionId}`, { headers, credentials: "include" });
if (!res.ok) { if (!res.ok) {
throw new Error("Session not found"); throw new Error("Session not found");
} }
@@ -96,8 +228,8 @@ function App() {
setActiveChapter(data.session.chapter || DEFAULTS.chapter); setActiveChapter(data.session.chapter || DEFAULTS.chapter);
} }
// Hide selector flow when loading an existing chat // Move to chat view
setShowSelectorFlow(false); setSelectorStep("chat");
// Load the messages // Load the messages
if (data.messages) { if (data.messages) {
@@ -121,8 +253,14 @@ function App() {
const handleDeleteChat = async (sessionId) => { const handleDeleteChat = async (sessionId) => {
try { try {
const token = await getToken();
const headers = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
const res = await fetch(`${API_BASE}/api/sessions/${sessionId}`, { const res = await fetch(`${API_BASE}/api/sessions/${sessionId}`, {
method: "DELETE", method: "DELETE",
headers,
credentials: "include",
}); });
if (res.ok) { if (res.ok) {
// If we were on the deleted chat, clear it // If we were on the deleted chat, clear it
@@ -138,7 +276,7 @@ function App() {
}; };
const handleSend = useCallback( const handleSend = useCallback(
(text) => { async (text) => {
if (!text.trim() || isTyping) return; if (!text.trim() || isTyping) return;
// Abort any previous in-flight request // Abort any previous in-flight request
@@ -166,9 +304,14 @@ function App() {
const controller = new AbortController(); const controller = new AbortController();
abortRef.current = controller; abortRef.current = controller;
const token = await getToken();
const headers = { "Content-Type": "application/json" };
if (token) headers["Authorization"] = `Bearer ${token}`;
fetch(`${API_BASE}/api/chat`, { fetch(`${API_BASE}/api/chat`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers,
credentials: "include",
body: JSON.stringify(body), body: JSON.stringify(body),
signal: controller.signal, signal: controller.signal,
}) })
@@ -271,39 +414,120 @@ function App() {
[handleSend] [handleSend]
); );
const handleGradeChange = (gradeLabel) => { /* ─── Selector step handlers ─── */
setActiveGrade(gradeLabel);
const handleGradeSelect = (item) => {
setActiveGrade(item.label);
setSelectorStep("subject");
}; };
const handleSubjectChange = (subjectId) => { const handleSubjectSelect = (item) => {
setActiveSubject(subjectId); setActiveSubject(item.id);
setActiveChapter(DEFAULTS.chapter);
setSelectorStep("chapter");
}; };
const handleChapterChange = (chapterLabel) => { const handleChapterSelect = (item) => {
setActiveChapter(chapterLabel); setActiveChapter(item.label);
setSelectorStep("chat");
}; };
/* ─── Selector step data ─── */
const selectorData = {
grade: {
title: "Select your standard",
subtitle: "Choose the grade you are studying in",
items: gradeItems,
selected: activeGrade,
onSelect: handleGradeSelect,
},
subject: {
title: "Select your subject",
subtitle: `Chose by grade ${activeGrade}`,
items: subjectItems,
selectedValue: activeSubject,
onSelect: handleSubjectSelect,
compareField: "id",
},
chapter: {
title: "Select a chapter",
subtitle: `Subject ${getSubjectLabel(activeSubject)}`,
items: getChaptersForSubject(activeSubject),
selectedValue: activeChapter,
onSelect: handleChapterSelect,
compareField: "label",
},
};
// ─── Not authenticated → show sign-in modal ───
if (!isAuthenticated) {
return (
<SignInModal
onSignIn={async (email, password) => {
try {
await signIn(email, password);
} catch (err) {
handleAuthError(err.message || "Sign in failed");
}
}}
onSignUp={async (email, password) => {
try {
await signUp(email, password);
} catch (err) {
handleAuthError(err.message || "Sign up failed");
}
}}
onError={handleAuthError}
/>
);
}
return ( return (
<div className="page"> <div className="page">
<Sidebar <Sidebar
activeSubject={activeSubject} activeSubject={activeSubject}
onSubjectChange={handleSubjectChange} onSubjectChange={(id) => {
setActiveSubject(id);
setActiveChapter(DEFAULTS.chapter);
if (selectorStep !== "chat") {
setSelectorStep("subject");
}
}}
activeChat={activeChat} activeChat={activeChat}
onChatSelect={handleChatSelect} onChatSelect={handleChatSelect}
onNewChat={handleNewChat} onNewChat={handleNewChat}
sessions={sessions} sessions={sessions}
onDeleteChat={handleDeleteChat} onDeleteChat={handleDeleteChat}
isLoadingSessions={isLoadingSessions} isLoadingSessions={isLoadingSessions}
showUpgrade={false}
user={user}
onSignOut={signOut}
/> />
<div className="page__main"> <div className="page__main">
<TopNav <TopNav
activeGrade={activeGrade} activeGrade={activeGrade}
onGradeChange={setActiveGrade} onGradeChange={(g) => setActiveGrade(g)}
subjectItems={subjectItems}
chapterData={chapterData}
activeSubject={activeSubject} activeSubject={activeSubject}
onSubjectChange={handleSubjectChange}
activeChapter={activeChapter} activeChapter={activeChapter}
onChapterChange={setActiveChapter} onChapterChange={(ch) => {
setActiveChapter(ch);
if (selectorStep !== "chat") {
setSelectorStep("chapter");
}
}}
onSubjectChange={(id) => {
setActiveSubject(id);
setActiveChapter(DEFAULTS.chapter);
if (selectorStep !== "chat") {
setSelectorStep("subject");
}
}}
user={user}
onSignOut={signOut}
/> />
<div className="main-content"> <div className="main-content">
@@ -314,16 +538,14 @@ function App() {
activeChapter={activeChapter} activeChapter={activeChapter}
onPromptClick={handlePromptClick} onPromptClick={handlePromptClick}
isTyping={isTyping} isTyping={isTyping}
showSelectorFlow={showSelectorFlow} selectorStep={selectorStep}
onGradeChange={handleGradeChange} selectorData={selectorStep !== "chat" ? selectorData[selectorStep] : null}
onSubjectChange={handleSubjectChange}
onChapterChange={handleChapterChange}
/> />
</div> </div>
<ChatInput <ChatInput
onSend={handleSend} onSend={handleSend}
placeholder={`Ask anything about ${activeGrade} ${activeSubject} ${activeChapter}...`} placeholder={`Ask anything about ${activeGrade} ${getSubjectLabel(activeSubject)} ${getChapterLabel(activeChapter)}...`}
disabled={isTyping} disabled={isTyping}
/> />
</div> </div>
+18
View File
@@ -0,0 +1,18 @@
export const DEFAULT_APP_STATE = Object.freeze({
messages: [],
activeChat: null,
activeGrade: "Choose standard",
activeSubject: "choose-subject",
activeChapter: "choose-chapter",
isTyping: false,
sessions: [],
selectorStep: "grade",
});
export function resetAppState() {
return {
...DEFAULT_APP_STATE,
messages: [],
sessions: [],
};
}
@@ -8,13 +8,16 @@ import "./ChatHistory.css";
const ChatHistory = ({ const ChatHistory = ({
messages, messages,
activeSubject,
activeGrade, activeGrade,
activeChapter,
onPromptClick, onPromptClick,
isTyping, isTyping,
showSelectorFlow, selectorStep,
onGradeChange, selectorData,
}) => { }) => {
const hasMessages = messages.length > 0; const hasMessages = messages.length > 0;
const isSelector = selectorStep !== "chat";
const suggestions = [ const suggestions = [
{ {
@@ -63,6 +66,7 @@ const ChatHistory = ({
const streamingMsg = messages.find((m) => m.streaming); const streamingMsg = messages.find((m) => m.streaming);
/* ─── Messages mode ─── */
if (hasMessages) { if (hasMessages) {
return ( return (
<div className="chat-history"> <div className="chat-history">
@@ -79,14 +83,23 @@ const ChatHistory = ({
); );
} }
if (showSelectorFlow) { /* ─── Selector mode (grade → subject → chapter) ─── */
if (isSelector && selectorData) {
return ( return (
<div className="chat-history"> <div className="chat-history">
<SelectorFlow grade={activeGrade} onGradeChange={onGradeChange} /> <SelectorFlow
title={selectorData.title}
subtitle={selectorData.subtitle}
selected={selectorData.selected}
items={selectorData.items}
onSelect={selectorData.onSelect}
step={selectorStep}
/>
</div> </div>
); );
} }
/* ─── Welcome / suggestions mode ─── */
return ( return (
<div className="chat-history"> <div className="chat-history">
<div className="chat-history__focus"> <div className="chat-history__focus">
@@ -6,6 +6,22 @@
min-height: 320px; min-height: 320px;
} }
/* Fade-in animation when step changes */
@keyframes selectorFadeIn {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.selector-flow__content {
animation: selectorFadeIn 0.35s ease forwards;
}
.selector-flow__content { .selector-flow__content {
width: 100%; width: 100%;
max-width: 640px; max-width: 640px;
@@ -50,11 +66,17 @@
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
color: var(--color-on-surface); color: var(--color-on-surface);
text-align: center; text-align: center;
transition: background-color var(--transition-fast), border-color var(--transition-fast), box-shadow var(--transition-fast), transform var(--transition-fast);
} }
.selector-flow__option:hover:not(.selector-flow__option--selected) { .selector-flow__option:hover:not(.selector-flow__option--selected) {
background-color: var(--color-surface-low); transform: translateY(-2px);
border-color: var(--color-outline); box-shadow: var(--shadow-card-hover);
}
.selector-flow__option:active:not(.selector-flow__option--selected) {
transform: translateY(0);
box-shadow: var(--shadow-card);
} }
.selector-flow__option--selected { .selector-flow__option--selected {
@@ -1,27 +1,35 @@
import { useState } from "react";
import "./SelectorFlow.css"; import "./SelectorFlow.css";
const grades = ["Grade 6", "Grade 7", "Grade 8", "Grade 9", "Grade 10", "Grade 11", "Grade 12"]; export default function SelectorFlow({
title,
export default function SelectorFlow({ grade, onGradeChange }) { subtitle,
const handleClick = (g) => onGradeChange(g); selectedValue,
items,
onSelect,
step,
compareField = "label",
}) {
return ( return (
<div className="selector-flow"> <div className="selector-flow" key={step}>
<div className="selector-flow__content"> <div className="selector-flow__content">
<h3 className="selector-flow__title">Select your standard</h3> <h3 className="selector-flow__title">{title}</h3>
<p className="selector-flow__subtitle">Choose the grade you are studying in</p> <p className="selector-flow__subtitle">{subtitle}</p>
<div className="selector-flow__options"> <div className="selector-flow__options">
{grades.map((g) => ( {items.map((item) => {
<button const isSelected = selectedValue === item[compareField];
key={g} return (
className={`selector-flow__option${grade === g ? " selector-flow__option--selected" : ""}`} <button
onClick={() => handleClick(g)} key={item.id}
> className={`selector-flow__option${isSelected ? " selector-flow__option--selected" : ""}`}
<span className="selector-flow__icon">school</span> onClick={() => onSelect(item)}
<span className="selector-flow__label">{g}</span> >
{item.icon && (
<span className="material-symbols-outlined selector-flow__icon">{item.icon}</span>
)}
<span className="selector-flow__label">{item.label}</span>
</button> </button>
))} );
})}
</div> </div>
</div> </div>
</div> </div>
@@ -365,6 +365,38 @@
font-style: italic; font-style: italic;
} }
/* User Info */
.sidebar__user {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 16px;
margin-bottom: 4px;
}
.sidebar__user-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--color-primary);
color: var(--color-on-primary);
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 600;
flex-shrink: 0;
}
.sidebar__user-email {
font-size: 12px;
color: var(--color-on-surface-variant);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
/* Footer */ /* Footer */
.sidebar__footer { .sidebar__footer {
display: flex; display: flex;
+25 -16
View File
@@ -13,6 +13,9 @@ const Sidebar = ({
sessions, sessions,
onDeleteChat, onDeleteChat,
isLoadingSessions, isLoadingSessions,
showUpgrade = true,
user,
onSignOut,
}) => { }) => {
const subjects = [ const subjects = [
{ id: "math", name: "Mathematics", icon: "functions" }, { id: "math", name: "Mathematics", icon: "functions" },
@@ -121,26 +124,32 @@ const Sidebar = ({
{/* Footer */} {/* Footer */}
<div className="sidebar__footer"> <div className="sidebar__footer">
<a href="#settings" className="sidebar__footer-btn"> {user && (
<span className="material-symbols-outlined sidebar__footer-icon">settings</span> <div className="sidebar__user">
<span>Settings</span> <span className="sidebar__user-avatar">
</a> {user.email?.charAt(0).toUpperCase() || "U"}
<a href="#help" className="sidebar__footer-btn"> </span>
<span className="material-symbols-outlined sidebar__footer-icon">help_outline</span> <span className="sidebar__user-email">{user.email}</span>
<span>Help &amp; Support</span> </div>
</a> )}
<button className="sidebar__footer-btn" onClick={onSignOut}>
<span className="material-symbols-outlined sidebar__footer-icon">logout</span>
<span>Sign out</span>
</button>
{/* Upgrade to Pro Card */} {/* Upgrade to Pro Card */}
<div className="sidebar__upgrade"> {showUpgrade && (
<div className="sidebar__upgrade-inner"> <div className="sidebar__upgrade">
<div className="sidebar__upgrade-header"> <div className="sidebar__upgrade-inner">
<span className="sidebar__upgrade-icon">👑</span> <div className="sidebar__upgrade-header">
<span className="sidebar__upgrade-title">Upgrade to Pro</span> <span className="sidebar__upgrade-icon">👑</span>
<span className="sidebar__upgrade-title">Upgrade to Pro</span>
</div>
<p className="sidebar__upgrade-text">Unlock study guides, quizzes and more!</p>
<span className="material-symbols-outlined sidebar__upgrade-arrow">chevron_right</span>
</div> </div>
<p className="sidebar__upgrade-text">Unlock study guides, quizzes and more!</p>
<span className="material-symbols-outlined sidebar__upgrade-arrow">chevron_right</span>
</div> </div>
</div> )}
</div> </div>
</aside> </aside>
); );
+8 -3
View File
@@ -184,7 +184,8 @@
} }
/* User Avatar */ /* User Avatar */
.topnav__avatar { .topnav__avatar,
.topnav__avatar-btn {
width: 32px; width: 32px;
height: 32px; height: 32px;
border-radius: 50%; border-radius: 50%;
@@ -197,8 +198,12 @@
transition: background-color var(--transition-fast); transition: background-color var(--transition-fast);
} }
.topnav__avatar:hover { .topnav__avatar-btn:hover {
background-color: var(--color-surface-lowest); background-color: #fee2e2;
}
.topnav__avatar-btn:hover .topnav__avatar-initial {
color: #dc2626;
} }
.topnav__avatar-initial { .topnav__avatar-initial {
+39 -33
View File
@@ -5,7 +5,7 @@
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import "./TopNav.css"; import "./TopNav.css";
const TopNav = ({ activeGrade, onGradeChange, activeSubject, onSubjectChange, activeChapter, onChapterChange }) => { const TopNav = ({ subjectItems, chapterData, activeGrade, onGradeChange, activeSubject, onSubjectChange, activeChapter, onChapterChange, user, onSignOut }) => {
const [openDropdown, setOpenDropdown] = useState(null); const [openDropdown, setOpenDropdown] = useState(null);
const gradeRef = useRef(null); const gradeRef = useRef(null);
const subjectRef = useRef(null); const subjectRef = useRef(null);
@@ -54,25 +54,9 @@ const TopNav = ({ activeGrade, onGradeChange, activeSubject, onSubjectChange, ac
{ id: "Grade 12", label: "Grade 12" }, { id: "Grade 12", label: "Grade 12" },
]; ];
const subjects = [ // Chapters are now generated dynamically from chapterData (see chapterOptions above)
{ id: "choose-subject", label: "Choose Subject" }, // Subjects now come from subjectItems prop
{ id: "math", label: "Mathematics" }, // Static list removed — was not subject-aware.
{ id: "science", label: "Science" },
{ id: "history", label: "History" },
{ id: "language", label: "Language Arts" },
{ id: "english", label: "English" },
{ id: "geography", label: "Geography" },
];
const chapters = [
{ id: "choose-chapter", label: "Choose Chapter" },
{ id: "chapter1", label: "Chapter 1: Rational Numbers" },
{ id: "chapter2", label: "Chapter 2: Linear Equations" },
{ id: "chapter3", label: "Chapter 3: Coordinate Geometry" },
{ id: "chapter4", label: "Chapter 4: Life Processes" },
{ id: "chapter5", label: "Chapter 5: Photosynthesis" },
{ id: "chapter6", label: "Chapter 6: Human Physiology" },
];
const subjectIcons = { const subjectIcons = {
math: "calculate", math: "calculate",
@@ -83,6 +67,25 @@ const TopNav = ({ activeGrade, onGradeChange, activeSubject, onSubjectChange, ac
geography: "public", geography: "public",
}; };
/* ─── Resolve subject display (ID → label) ─── */
const resolvedSubjectLabel = activeSubject === "choose-subject"
? "Choose Subject"
: subjectItems?.find((s) => s.id === activeSubject)?.label || activeSubject || "Choose Subject";
/* ─── Resolve chapter pill label ─── */
const resolvedChapterLabel = activeChapter === "choose-chapter" || !activeChapter
? "Choose Chapter"
: activeChapter;
/* ─── Subject-aware chapter options (from chapterData prop) ─── */
const chapterOptions = (() => {
const chapters = chapterData?.[activeSubject] || [];
return [
{ id: "choose-chapter", label: "Choose Chapter" },
...chapters,
];
})();
return ( return (
<header className="topnav"> <header className="topnav">
<div className="topnav__selectors"> <div className="topnav__selectors">
@@ -125,16 +128,12 @@ const TopNav = ({ activeGrade, onGradeChange, activeSubject, onSubjectChange, ac
<span className={`material-symbols-outlined topnav__pill-icon`}> <span className={`material-symbols-outlined topnav__pill-icon`}>
{activeSubject === "choose-subject" ? "science" : subjectIcons[activeSubject] || "science"} {activeSubject === "choose-subject" ? "science" : subjectIcons[activeSubject] || "science"}
</span> </span>
<span className="topnav__pill-label"> <span className="topnav__pill-label">{resolvedSubjectLabel}</span>
{activeSubject === "choose-subject"
? "Choose Subject"
: subjects.find((s) => s.id === activeSubject)?.label || activeSubject}
</span>
<span className="material-symbols-outlined topnav__pill-arrow">expand_more</span> <span className="material-symbols-outlined topnav__pill-arrow">expand_more</span>
</button> </button>
{openDropdown === "subject" && ( {openDropdown === "subject" && (
<ul className="topnav__dropdown"> <ul className="topnav__dropdown">
{subjects.map((subject) => ( {subjectItems?.map((subject) => (
<li key={subject.id}> <li key={subject.id}>
<button <button
className={`topnav__dropdown-item ${activeSubject === subject.id ? "topnav__dropdown-item--active" : ""}`} className={`topnav__dropdown-item ${activeSubject === subject.id ? "topnav__dropdown-item--active" : ""}`}
@@ -160,15 +159,15 @@ const TopNav = ({ activeGrade, onGradeChange, activeSubject, onSubjectChange, ac
aria-expanded={openDropdown === "chapter"} aria-expanded={openDropdown === "chapter"}
> >
<span className="material-symbols-outlined topnav__pill-icon">menu_book</span> <span className="material-symbols-outlined topnav__pill-icon">menu_book</span>
<span className="topnav__pill-label">{activeChapter}</span> <span className="topnav__pill-label">{resolvedChapterLabel}</span>
<span className="material-symbols-outlined topnav__pill-arrow">expand_more</span> <span className="material-symbols-outlined topnav__pill-arrow">expand_more</span>
</button> </button>
{openDropdown === "chapter" && ( {openDropdown === "chapter" && (
<ul className="topnav__dropdown"> <ul className="topnav__dropdown">
{chapters.map((chapter) => ( {chapterOptions.map((chapter) => (
<li key={chapter.id}> <li key={chapter.id}>
<button <button
className={`topnav__dropdown-item ${activeChapter === chapter.label ? "topnav__dropdown-item--active" : ""}`} className={`topnav__dropdown-item ${(activeChapter === chapter.label || activeChapter === "choose-chapter") ? "topnav__dropdown-item--active" : ""}`}
onClick={() => selectOption("chapter", chapter.label)} onClick={() => selectOption("chapter", chapter.label)}
> >
{chapter.label} {chapter.label}
@@ -191,10 +190,17 @@ const TopNav = ({ activeGrade, onGradeChange, activeSubject, onSubjectChange, ac
<span className="material-symbols-outlined">notifications_none</span> <span className="material-symbols-outlined">notifications_none</span>
</button> </button>
{/* User Avatar */} {/* User Avatar — Sign out on click */}
<div className="topnav__avatar"> <button
<span className="topnav__avatar-initial">S</span> className="topnav__avatar-btn"
</div> onClick={onSignOut}
aria-label="Sign out"
title="Sign out"
>
<span className="topnav__avatar-initial">
{user?.email?.charAt(0).toUpperCase() || "U"}
</span>
</button>
</div> </div>
</header> </header>
); );
+119
View File
@@ -0,0 +1,119 @@
/* ──────────────────────────────────────────
Sign-In Modal — padhle
────────────────────────────────────────── */
.signin-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.45);
z-index: 10000;
backdrop-filter: blur(4px);
}
.signin-card {
background: var(--color-surface-lowest, #fff);
border-radius: 16px;
padding: 40px 36px;
width: 100%;
max-width: 400px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
}
.signin-title {
font-family: var(--font-heading);
font-size: 1.5rem;
font-weight: 700;
color: var(--color-on-surface);
margin: 0 0 6px;
}
.signin-subtitle {
font-size: 0.9rem;
color: var(--color-on-surface-variant);
margin: 0 0 28px;
}
.signin-form {
display: flex;
flex-direction: column;
gap: 16px;
}
.signin-label {
font-size: 0.82rem;
font-weight: 600;
color: var(--color-on-surface);
margin-bottom: 4px;
}
.signin-input {
width: 100%;
padding: 10px 14px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.95rem;
font-family: var(--font-body);
color: var(--color-on-surface);
background: var(--color-surface-lowest);
outline: none;
transition: border-color 0.2s;
}
.signin-input:focus {
border-color: var(--color-primary, #000);
}
.signin-btn {
margin-top: 8px;
padding: 12px;
background: var(--color-primary, #000);
color: var(--color-on-primary, #fff);
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
font-family: var(--font-body);
cursor: pointer;
transition: opacity 0.2s;
}
.signin-btn:hover:not(:disabled) {
opacity: 0.9;
}
.signin-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.signin-footer {
margin-top: 20px;
font-size: 0.88rem;
color: var(--color-on-surface-variant);
text-align: center;
}
.signin-link {
background: none;
border: none;
color: var(--color-primary, #000);
font-weight: 600;
font-family: var(--font-body);
cursor: pointer;
text-decoration: underline;
}
.signin-link:hover {
text-decoration: none;
}
.signin-note {
margin-top: 16px;
font-size: 0.75rem;
color: var(--color-on-surface-variant);
text-align: center;
opacity: 0.7;
}
+97
View File
@@ -0,0 +1,97 @@
import { useState } from "react";
import "./SignIn.css";
const SignInModal = ({ onSignIn, onSignUp, onError }) => {
const [mode, setMode] = useState("signin");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
try {
if (mode === "signin") {
await onSignIn(email, password);
} else {
await onSignUp(email, password);
}
setEmail("");
setPassword("");
} catch (err) {
if (onError) onError(err.message || "Authentication failed");
} finally {
setLoading(false);
}
};
return (
<div className="signin-overlay">
<div className="signin-card">
<h2 className="signin-title">
{mode === "signin" ? "Sign in to padhle" : "Create your account"}
</h2>
<p className="signin-subtitle">
{mode === "signin"
? "Welcome back! Enter your credentials."
: "Start learning with AI-powered tutoring."}
</p>
<form onSubmit={handleSubmit} className="signin-form">
<label className="signin-label" htmlFor="signin-email">Email</label>
<input
className="signin-input"
id="signin-email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
/>
<label className="signin-label" htmlFor="signin-password">Password</label>
<input
className="signin-input"
id="signin-password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete={mode === "signin" ? "current-password" : "new-password"}
minLength={6}
/>
<button className="signin-btn" type="submit" disabled={loading}>
{loading ? "Please wait..." : mode === "signin" ? "Sign in" : "Create account"}
</button>
</form>
<p className="signin-footer">
{mode === "signin" ? (
<>
Don't have an account?{" "}
<button className="signin-link" type="button" onClick={() => setMode("signup")}>
Sign up
</button>
</>
) : (
<>
Already have an account?{" "}
<button className="signin-link" type="button" onClick={() => setMode("signin")}>
Sign in
</button>
</>
)}
</p>
<p className="signin-note">
By continuing, you agree to padhle's Terms of Service.
</p>
</div>
</div>
);
};
export default SignInModal;
+64
View File
@@ -0,0 +1,64 @@
import { createContext, useContext, useEffect, useState } from "react";
import { auth } from "./backendAuth";
const AuthContext = createContext(null);
/**
* SupabaseAuth — provides auth state and helper methods to the tree.
*
* All auth is handled server-side via httpOnly cookies.
* The frontend never sees raw tokens.
*/
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
// Restore session on mount (from httpOnly cookie)
useEffect(() => {
auth.me().then(({ user }) => {
setUser(user);
});
}, []);
const signIn = async (email, password) => {
const data = await auth.signin(email, password);
setUser(data.user);
return data;
};
const signUp = async (email, password) => {
const data = await auth.signup(email, password);
setUser(data.user);
return data;
};
const signOut = async () => {
await auth.signout();
setUser(null);
};
const getToken = async () => {
// Token lives in httpOnly cookie — not accessible to JavaScript
return null;
};
return (
<AuthContext.Provider
value={{
user,
isAuthenticated: !!user,
signIn,
signUp,
signOut,
getToken,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}
+88
View File
@@ -0,0 +1,88 @@
/**
* Backend-auth utility.
* All auth operations go through the backend.
* Tokens live in httpOnly cookies — never exposed to JavaScript.
*/
const API_BASE = import.meta.env.VITE_API_URL || "http://localhost:3001";
async function authRequest(endpoint, options = {}) {
const url = `${API_BASE}${endpoint}`;
const res = await fetch(url, {
...options,
credentials: "include", // ← sends cookies automatically
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
const data = await res.json();
return { res, data };
}
export const auth = {
/**
* Sign up with email and password.
* Backend sets httpOnly cookies with the JWT.
*/
async signup(email, password) {
const { res, data } = await authRequest("/api/auth/signup", {
method: "POST",
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
throw new Error(data.error || "Signup failed");
}
return data;
},
/**
* Sign in with email and password.
* Backend sets httpOnly cookies with the JWT.
*/
async signin(email, password) {
const { res, data } = await authRequest("/api/auth/signin", {
method: "POST",
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
throw new Error(data.error || "Sign in failed");
}
return data;
},
/**
* Sign out.
* Backend revokes session and clears cookies.
*/
async signout() {
await authRequest("/api/auth/signout", {
method: "POST",
});
},
/**
* Get current user from the httpOnly cookie.
*/
async me() {
const { res, data } = await authRequest("/api/auth/me");
if (!res.ok) {
return { user: null };
}
return data;
},
/**
* Check if user is authenticated.
* Backend verifies the httpOnly cookie.
*/
async check() {
const { user } = await auth.me();
return !!user;
},
};
+4 -1
View File
@@ -5,10 +5,13 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import App from "./App.jsx"; import App from "./App.jsx";
import { AuthProvider } from "./lib/auth/SupabaseAuth.jsx";
import "./styles/global.css"; import "./styles/global.css";
ReactDOM.createRoot(document.getElementById("root")).render( ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode> <React.StrictMode>
<App /> <AuthProvider>
<App />
</AuthProvider>
</React.StrictMode> </React.StrictMode>
); );
+7
View File
@@ -0,0 +1,7 @@
import test from "node:test";
import assert from "node:assert/strict";
import { DEFAULT_APP_STATE, resetAppState } from "../src/appState.js";
test("resetAppState clears user-specific conversation state", () => {
assert.deepEqual(resetAppState(), DEFAULT_APP_STATE);
});
+381
View File
@@ -8,6 +8,7 @@
"name": "padhle", "name": "padhle",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"pg": "^8.23.0",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8" "react-dom": "^19.2.8"
}, },
@@ -17,9 +18,67 @@
"@vitejs/plugin-react": "^6.0.4", "@vitejs/plugin-react": "^6.0.4",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"oxlint": "^1.75.0", "oxlint": "^1.75.0",
"supabase": "^2.115.0",
"vite": "^8.2.0" "vite": "^8.2.0"
} }
}, },
"node_modules/@ecies/ciphers": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/@ecies/ciphers/-/ciphers-0.2.6.tgz",
"integrity": "sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g==",
"dev": true,
"license": "MIT",
"engines": {
"bun": ">=1",
"deno": ">=2.7.10",
"node": ">=16"
},
"peerDependencies": {
"@noble/ciphers": "^1.0.0"
}
},
"node_modules/@noble/ciphers": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz",
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/curves": {
"version": "1.9.7",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz",
"integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@noble/hashes": "1.8.0"
},
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@oxc-project/types": { "node_modules/@oxc-project/types": {
"version": "0.144.0", "version": "0.144.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz",
@@ -640,6 +699,130 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@supabase/cli-darwin-arm64": {
"version": "2.115.0",
"resolved": "https://registry.npmjs.org/@supabase/cli-darwin-arm64/-/cli-darwin-arm64-2.115.0.tgz",
"integrity": "sha512-yUNw1KG+fyuBqBGvFT8ASC7aAkFkx2Kx+qPjUTW50ttiKKgg8D/eMSCoSjQuaAbL0vafw98bHEZ2dAfdyYyXMA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@supabase/cli-darwin-x64": {
"version": "2.115.0",
"resolved": "https://registry.npmjs.org/@supabase/cli-darwin-x64/-/cli-darwin-x64-2.115.0.tgz",
"integrity": "sha512-e4bbWADYcjSjXgSSErreoqyEeEjrFQunxCYummUoiGdVanck/itAIFwhtRykKpBtoKqCYh4CFxUiDxOFMBdNHQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@supabase/cli-linux-arm64": {
"version": "2.115.0",
"resolved": "https://registry.npmjs.org/@supabase/cli-linux-arm64/-/cli-linux-arm64-2.115.0.tgz",
"integrity": "sha512-JBcnnFuVekMR9+EOCcup1QihW+CHMBWcL/+N1Uz4HB6leX8d894VQ+sX0pFgbXLqnpGcc35IiXKwjLz4FCb8Dw==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@supabase/cli-linux-arm64-musl": {
"version": "2.115.0",
"resolved": "https://registry.npmjs.org/@supabase/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.115.0.tgz",
"integrity": "sha512-2OCzD4qZx8RFbW2vfOlXpup+1UnVqSauUsSGH4mKiUIveMD/UyMI6Md8CLKLszwV2XwM+6bZG6w7d7Ems9vxCQ==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@supabase/cli-linux-x64": {
"version": "2.115.0",
"resolved": "https://registry.npmjs.org/@supabase/cli-linux-x64/-/cli-linux-x64-2.115.0.tgz",
"integrity": "sha512-ZvZ5QbPB3cvenEam6TDgngWPPm9GBO5m/5GYCOsqIfx2Gz5++WsEhlypWy9U4gTFae7ENtih0RqPzgiyWDKE+w==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@supabase/cli-linux-x64-musl": {
"version": "2.115.0",
"resolved": "https://registry.npmjs.org/@supabase/cli-linux-x64-musl/-/cli-linux-x64-musl-2.115.0.tgz",
"integrity": "sha512-t36QEEQxy0AsOn0rr1L8aEzZKYS+kRH4fvYr5KdB+rHA1x39A1dUeOCTqCucCr0V5oQ3+odJLlO6tinNczHKMQ==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@supabase/cli-windows-arm64": {
"version": "2.115.0",
"resolved": "https://registry.npmjs.org/@supabase/cli-windows-arm64/-/cli-windows-arm64-2.115.0.tgz",
"integrity": "sha512-MkYbWrNZXpWZxwglVaTexj+w6EWYUi34c7Y9SRGbcQBC/infteM/8zhVVRUmO3ltSEqnJCacHowLPISXIF6g/w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@supabase/cli-windows-x64": {
"version": "2.115.0",
"resolved": "https://registry.npmjs.org/@supabase/cli-windows-x64/-/cli-windows-x64-2.115.0.tgz",
"integrity": "sha512-jRXsJjbw/h0ssSpFQeClTTO2wtMw0YYS1yMzN2f/px4DehigBZNxSFB4thOtzmIJoisInm1gYV5pQJZla1Ytpw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "19.2.18", "version": "19.2.18",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
@@ -819,6 +1002,24 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/eciesjs": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.5.0.tgz",
"integrity": "sha512-s0J9SEVYAEPg7J63GFMApLYzPH9VNIQIyC6s15JpnqVc0TqcKWdbgFlnAweEBRyMmko2dcs2sfC83Hj4J43tuA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@ecies/ciphers": "^0.2.6",
"@noble/ciphers": "^1.3.0",
"@noble/curves": "^1.9.7",
"@noble/hashes": "^1.8.0"
},
"engines": {
"bun": ">=1",
"deno": ">=2.7.10",
"node": ">=16"
}
},
"node_modules/emoji-regex": { "node_modules/emoji-regex": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
@@ -899,6 +1100,16 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/jose": {
"version": "6.2.9",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.9.tgz",
"integrity": "sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/lightningcss": { "node_modules/lightningcss": {
"version": "1.33.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
@@ -1240,6 +1451,95 @@
} }
} }
}, },
"node_modules/pg": {
"version": "8.23.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
"integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.14.0",
"pg-pool": "^3.14.0",
"pg-protocol": "^1.16.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.4.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.14.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
"integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",
"integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1289,6 +1589,45 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react": { "node_modules/react": {
"version": "19.2.8", "version": "19.2.8",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
@@ -1392,6 +1731,15 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/string-width": { "node_modules/string-width": {
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@@ -1420,6 +1768,30 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/supabase": {
"version": "2.115.0",
"resolved": "https://registry.npmjs.org/supabase/-/supabase-2.115.0.tgz",
"integrity": "sha512-8fL9vOd6jOntmU8N5DVlHGE2GWR1r57ulsrOzSyO6IRYq5QMyKie8T8DH+hb+caGhYUVJLvmpY7XYwic60Uafg==",
"dev": true,
"license": "MIT",
"dependencies": {
"eciesjs": "^0.5.0",
"jose": "^6.2.8"
},
"bin": {
"supabase": "dist/supabase.js"
},
"optionalDependencies": {
"@supabase/cli-darwin-arm64": "2.115.0",
"@supabase/cli-darwin-x64": "2.115.0",
"@supabase/cli-linux-arm64": "2.115.0",
"@supabase/cli-linux-arm64-musl": "2.115.0",
"@supabase/cli-linux-x64": "2.115.0",
"@supabase/cli-linux-x64-musl": "2.115.0",
"@supabase/cli-windows-arm64": "2.115.0",
"@supabase/cli-windows-x64": "2.115.0"
}
},
"node_modules/supports-color": { "node_modules/supports-color": {
"version": "8.1.1", "version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
@@ -1566,6 +1938,15 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1" "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
} }
}, },
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
},
"node_modules/y18n": { "node_modules/y18n": {
"version": "5.0.8", "version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+2
View File
@@ -11,6 +11,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"pg": "^8.23.0",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8" "react-dom": "^19.2.8"
}, },
@@ -20,6 +21,7 @@
"@vitejs/plugin-react": "^6.0.4", "@vitejs/plugin-react": "^6.0.4",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"oxlint": "^1.75.0", "oxlint": "^1.75.0",
"supabase": "^2.115.0",
"vite": "^8.2.0" "vite": "^8.2.0"
} }
} }
+38
View File
@@ -0,0 +1,38 @@
import pkg from 'pg';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const { Client } = pkg;
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const client = new Client({
host: '127.0.0.1',
port: 54322,
database: 'postgres',
user: 'postgres',
password: 'postgres',
});
async function runMigration() {
try {
await client.connect();
console.log('✅ Connected to Supabase Postgres');
const migrationFile = path.join(process.cwd(), 'supabase/migrations/20260819064500_create_sessions_messages.sql');
const sql = fs.readFileSync(migrationFile, 'utf-8');
// Execute full migration as one transaction
await client.query(sql);
console.log('✅ Migration applied successfully!');
} catch (err) {
console.error('❌ Migration failed:', err.message);
process.exit(1);
} finally {
await client.end();
}
}
runMigration();
+8
View File
@@ -0,0 +1,8 @@
# Supabase
.branches
.temp
# dotenvx
.env.keys
.env.local
.env.*.local
+414
View File
@@ -0,0 +1,414 @@
# For detailed configuration reference documentation, visit:
# https://supabase.com/docs/guides/local-development/cli/config
# A string used to distinguish different Supabase projects on the same host. Defaults to the
# working directory name when running `supabase init`.
project_id = "padhle"
[api]
enabled = true
# Port to use for the API URL.
port = 54321
# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
# endpoints. `public` and `graphql_public` schemas are included by default.
schemas = ["public", "graphql_public"]
# Extra schemas to add to the search_path of every request.
extra_search_path = ["public", "extensions"]
# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
# for accidental or malicious requests.
max_rows = 1000
# Controls whether new tables, views, sequences and functions created in the `public` schema by
# `postgres` are reachable through the Data API roles (`anon`, `authenticated`, `service_role`)
# without explicit GRANTs. When unset, new entities are NOT auto-exposed, matching the new cloud
# default. Set to `true` to keep the legacy behaviour of auto-exposing new entities; this is
# deprecated and the field is removed on 2026-10-30 once the always-revoked behaviour is permanent.
# auto_expose_new_tables = true
[api.tls]
# Enable HTTPS endpoints locally using a self-signed certificate.
enabled = false
# Paths to self-signed certificate pair.
# cert_path = "../certs/my-cert.pem"
# key_path = "../certs/my-key.pem"
[db]
# Port to use for the local database URL.
port = 54322
# Port used by db diff command to initialize the shadow database.
shadow_port = 54320
# Maximum amount of time to wait for health check when starting the local database.
health_timeout = "2m"
# The database major version to use. This has to be the same as your remote database's. Run `SHOW
# server_version;` on the remote database to check.
major_version = 17
[db.pooler]
enabled = false
# Port to use for the local connection pooler.
port = 54329
# Specifies when a server connection can be reused by other clients.
# Configure one of the supported pooler modes: `transaction`, `session`.
pool_mode = "transaction"
# How many server connections to allow per user/database pair.
default_pool_size = 20
# Maximum number of client connections allowed.
max_client_conn = 100
# [db.vault]
# secret_key = "env(SECRET_VALUE)"
[db.migrations]
# If disabled, migrations will be skipped during a db push or reset.
enabled = true
# Specifies an ordered list of schema files, directories, or glob patterns that describe your database.
# Supports paths relative to supabase directory: "./schemas/*.sql", "./database".
schema_paths = []
[db.seed]
# If enabled, seeds the database after migrations during a db reset.
enabled = true
# Specifies an ordered list of seed files to load during db reset.
# Supports glob patterns relative to supabase directory: "./seeds/*.sql"
sql_paths = ["./seed.sql"]
[db.network_restrictions]
# Enable management of network restrictions.
enabled = false
# List of IPv4 CIDR blocks allowed to connect to the database.
# Defaults to allow all IPv4 connections. Set empty array to block all IPs.
allowed_cidrs = ["0.0.0.0/0"]
# List of IPv6 CIDR blocks allowed to connect to the database.
# Defaults to allow all IPv6 connections. Set empty array to block all IPs.
allowed_cidrs_v6 = ["::/0"]
# Uncomment to reject non-secure connections to the database.
# [db.ssl_enforcement]
# enabled = true
[realtime]
enabled = true
# Bind realtime via either IPv4 or IPv6. (default: IPv4)
# ip_version = "IPv6"
# The maximum length in bytes of HTTP request headers. (default: 4096)
# max_header_length = 4096
[studio]
enabled = true
# Port to use for Supabase Studio.
port = 54323
# External URL of the API server that frontend connects to.
api_url = "http://127.0.0.1"
# OpenAI API Key to use for Supabase AI in the Supabase Studio.
openai_api_key = "env(OPENAI_API_KEY)"
# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
# are monitored, and you can view the emails that would have been sent from the web interface.
[local_smtp]
enabled = true
# Port to use for the email testing server web interface.
port = 54324
# Uncomment to expose additional ports for testing user applications that send emails.
# smtp_port = 54325
# pop3_port = 54326
# admin_email = "admin@email.com"
# sender_name = "Admin"
[storage]
enabled = true
# The maximum file size allowed (e.g. "5MB", "500KB").
file_size_limit = "50MiB"
# Uncomment to configure local storage buckets
# [storage.buckets.images]
# public = false
# file_size_limit = "50MiB"
# allowed_mime_types = ["image/png", "image/jpeg"]
# objects_path = "./images"
# Allow connections via S3 compatible clients
[storage.s3_protocol]
enabled = true
# Image transformation API is available to Supabase Pro plan.
# [storage.image_transformation]
# enabled = true
# Store analytical data in S3 for running ETL jobs over Iceberg Catalog
# This feature is only available on the hosted platform.
[storage.analytics]
enabled = false
max_namespaces = 5
max_tables = 10
max_catalogs = 2
# Analytics Buckets is available to Supabase Pro plan.
# [storage.analytics.buckets.my-warehouse]
# Store vector embeddings in S3 for large and durable datasets
[storage.vector]
enabled = true
max_buckets = 10
max_indexes = 5
# Vector Buckets is available to Supabase Pro plan.
# [storage.vector.buckets.documents-openai]
[auth]
enabled = true
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
# in emails.
site_url = "http://127.0.0.1:3000"
# The public URL that Auth serves on. Defaults to the API external URL with `/auth/v1` appended.
# external_url = ""
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
additional_redirect_urls = ["https://127.0.0.1:3000"]
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
jwt_expiry = 3600
# JWT issuer URL. If not set, defaults to auth.external_url.
# jwt_issuer = ""
# Path to JWT signing key. DO NOT commit your signing keys file to git.
# signing_keys_path = "./signing_keys.json"
# If disabled, the refresh token will never expire.
enable_refresh_token_rotation = true
# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.
# Requires enable_refresh_token_rotation = true.
refresh_token_reuse_interval = 10
# Allow/disallow new user signups to your project.
enable_signup = true
# Allow/disallow anonymous sign-ins to your project.
enable_anonymous_sign_ins = false
# Allow/disallow testing manual linking of accounts
enable_manual_linking = false
# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more.
minimum_password_length = 6
# Passwords that do not meet the following requirements will be rejected as weak. Supported values
# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols`
password_requirements = ""
# Configure passkey sign-ins.
# [auth.passkey]
# enabled = false
# Configure WebAuthn relying party settings (required when passkey is enabled).
# [auth.webauthn]
# rp_display_name = "Supabase"
# rp_id = "localhost"
# rp_origins = ["http://127.0.0.1:3000"]
[auth.rate_limit]
# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled.
email_sent = 2
# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled.
sms_sent = 30
# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true.
anonymous_users = 30
# Number of sessions that can be refreshed in a 5 minute interval per IP address.
token_refresh = 150
# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users).
sign_in_sign_ups = 30
# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address.
token_verifications = 30
# Number of Web3 logins that can be made in a 5 minute interval per IP address.
web3 = 30
# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`.
# [auth.captcha]
# enabled = true
# provider = "hcaptcha"
# secret = ""
[auth.email]
# Allow/disallow new user signups via email to your project.
enable_signup = true
# If enabled, a user will be required to confirm any email change on both the old, and new email
# addresses. If disabled, only the new email is required to confirm.
double_confirm_changes = true
# If enabled, users need to confirm their email address before signing in.
enable_confirmations = false
# If enabled, users will need to reauthenticate or have logged in recently to change their password.
secure_password_change = false
# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email.
max_frequency = "1s"
# Number of characters used in the email OTP.
otp_length = 6
# Number of seconds before the email OTP expires (defaults to 1 hour).
otp_expiry = 3600
# Use a production-ready SMTP server
# [auth.email.smtp]
# enabled = true
# host = "smtp.sendgrid.net"
# port = 587
# user = "apikey"
# pass = "env(SENDGRID_API_KEY)"
# admin_email = "admin@email.com"
# sender_name = "Admin"
# Uncomment to customize email template
# [auth.email.template.invite]
# subject = "You have been invited"
# content_path = "./supabase/templates/invite.html"
# Uncomment to customize notification email template
# [auth.email.notification.password_changed]
# enabled = true
# subject = "Your password has been changed"
# content_path = "./supabase/templates/password_changed_notification.html"
[auth.sms]
# Allow/disallow new user signups via SMS to your project.
enable_signup = false
# If enabled, users need to confirm their phone number before signing in.
enable_confirmations = false
# Template for sending OTP to users
template = "Your code is {{ .Code }}"
# Controls the minimum amount of time that must pass before sending another sms otp.
max_frequency = "5s"
# Use pre-defined map of phone number to OTP for testing.
# [auth.sms.test_otp]
# 4152127777 = "123456"
# Configure logged in session timeouts.
# [auth.sessions]
# Force log out after the specified duration.
# timebox = "24h"
# Force log out if the user has been inactive longer than the specified duration.
# inactivity_timeout = "8h"
# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object.
# [auth.hook.before_user_created]
# enabled = true
# uri = "pg-functions://postgres/auth/before-user-created-hook"
# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used.
# [auth.hook.custom_access_token]
# enabled = true
# uri = "pg-functions://<database>/<schema>/<hook_name>"
# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`.
[auth.sms.twilio]
enabled = false
account_sid = ""
message_service_sid = ""
# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead:
auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)"
# Multi-factor-authentication is available to Supabase Pro plan.
[auth.mfa]
# Control how many MFA factors can be enrolled at once per user.
max_enrolled_factors = 10
# Control MFA via App Authenticator (TOTP)
[auth.mfa.totp]
enroll_enabled = false
verify_enabled = false
# Configure MFA via Phone Messaging
[auth.mfa.phone]
enroll_enabled = false
verify_enabled = false
otp_length = 6
template = "Your code is {{ .Code }}"
max_frequency = "5s"
# Configure MFA via WebAuthn
# [auth.mfa.web_authn]
# enroll_enabled = true
# verify_enabled = true
# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`,
# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`,
# `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`.
[auth.external.apple]
enabled = false
client_id = ""
# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead:
secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)"
# Overrides the default auth callback URL derived from auth.external_url.
redirect_uri = ""
# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
# or any other third-party OIDC providers.
url = ""
# If enabled, the nonce check will be skipped. Required for local sign in with Google auth.
skip_nonce_check = false
# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address.
email_optional = false
# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard.
# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting.
[auth.web3.solana]
enabled = false
# Use Firebase Auth as a third-party provider alongside Supabase Auth.
[auth.third_party.firebase]
enabled = false
# project_id = "my-firebase-project"
# Use Auth0 as a third-party provider alongside Supabase Auth.
[auth.third_party.auth0]
enabled = false
# tenant = "my-auth0-tenant"
# tenant_region = "us"
# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth.
[auth.third_party.aws_cognito]
enabled = false
# user_pool_id = "my-user-pool-id"
# user_pool_region = "us-east-1"
# Use Clerk as a third-party provider alongside Supabase Auth.
[auth.third_party.clerk]
enabled = false
# Obtain from https://clerk.com/setup/supabase
# domain = "example.clerk.accounts.dev"
# OAuth server configuration
[auth.oauth_server]
# Enable OAuth server functionality
enabled = false
# Path for OAuth consent flow UI
authorization_url_path = "/oauth/consent"
# Allow dynamic client registration
allow_dynamic_registration = false
[edge_runtime]
enabled = true
# Supported request policies: `oneshot`, `per_worker`.
# `per_worker` (default) — enables hot reload during local development.
# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks).
policy = "per_worker"
# Port to attach the Chrome inspector for debugging edge functions.
inspector_port = 8083
# The Deno major version to use.
deno_version = 2
# [edge_runtime.secrets]
# secret_key = "env(SECRET_VALUE)"
[analytics]
enabled = true
port = 54327
# Configure one of the supported backends: `postgres`, `bigquery`.
backend = "postgres"
# Experimental features may be deprecated any time
[experimental]
# Configures Postgres storage engine to use OrioleDB (S3)
orioledb_version = ""
# Configures S3 bucket URL, eg. <bucket_name>.s3-<region>.amazonaws.com
s3_host = "env(S3_HOST)"
# Configures S3 bucket region, eg. us-east-1
s3_region = "env(S3_REGION)"
# Configures AWS_ACCESS_KEY_ID for S3 bucket
s3_access_key = "env(S3_ACCESS_KEY)"
# Configures AWS_SECRET_ACCESS_KEY for S3 bucket
s3_secret_key = "env(S3_SECRET_KEY)"
# pg-delta is the schema diff engine for db diff / db pull / db remote commit.
# Set enabled = false to fall back to the legacy migra engine.
[experimental.pgdelta]
enabled = true
# Directory under `supabase/` where declarative files are written.
# declarative_schema_path = "./schemas"
# JSON string passed through to pg-delta SQL formatting.
# format_options = "{\"keywordCase\":\"upper\",\"indent\":2,\"maxWidth\":80,\"commaStyle\":\"trailing\"}"
@@ -0,0 +1,80 @@
-- Create sessions table
CREATE TABLE public.sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
grade TEXT,
subject TEXT,
chapter TEXT,
preview TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Create messages table
CREATE TABLE public.messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES public.sessions(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('user', 'assistant')),
text TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
-- Create profiles table (optional, for future user metadata)
CREATE TABLE public.profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
email TEXT NOT NULL,
display_name TEXT,
avatar_url TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Create indexes for faster queries
CREATE INDEX idx_sessions_user_id ON public.sessions(user_id);
CREATE INDEX idx_messages_session_id ON public.messages(session_id);
-- Enable RLS
ALTER TABLE public.sessions ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.messages ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
-- RLS Policies for sessions: users can CRUD only their own sessions
CREATE POLICY "Users can view own sessions"
ON public.sessions FOR SELECT
USING (auth.uid() = user_id);
CREATE POLICY "Users can create sessions"
ON public.sessions FOR INSERT
WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Users can update own sessions"
ON public.sessions FOR UPDATE
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Users can delete own sessions"
ON public.sessions FOR DELETE
USING (auth.uid() = user_id);
-- RLS Policies for messages: users can CRUD only messages in their own sessions
CREATE POLICY "Users can view messages in own sessions"
ON public.messages FOR SELECT
USING (session_id IN (SELECT id FROM public.sessions WHERE user_id = auth.uid()));
CREATE POLICY "Users can insert messages in own sessions"
ON public.messages FOR INSERT
WITH CHECK (session_id IN (SELECT id FROM public.sessions WHERE user_id = auth.uid()));
CREATE POLICY "Users can delete own messages"
ON public.messages FOR DELETE
USING (session_id IN (SELECT id FROM public.sessions WHERE user_id = auth.uid()));
-- RLS Policies for profiles
CREATE POLICY "Users can view own profile"
ON public.profiles FOR SELECT
USING (auth.uid() = id);
CREATE POLICY "Users can update own profile"
ON public.profiles FOR UPDATE
USING (auth.uid() = id)
WITH CHECK (auth.uid() = id);
@@ -0,0 +1,24 @@
-- Grant table privileges to Supabase API roles.
--
-- The sessions/messages/profiles tables are owned by `postgres`, and the
-- default ACL for postgres-owned tables omits SELECT/INSERT/UPDATE for the
-- PostgREST API roles (anon/authenticated/service_role). This caused
-- PostgreSQL error 42501 ("permission denied for table ...") on every
-- database operation through the REST gateway.
--
-- RLS remains enabled and is the actual access control; these grants only
-- allow the roles to reach the tables through PostgREST. service_role also
-- carries BYPASSRLS, which is the backend's write path.
GRANT SELECT, INSERT, UPDATE, DELETE
ON public.profiles,
public.sessions,
public.messages
TO anon, authenticated, service_role;
-- Ensure future tables created in `public` receive the same grants, so the
-- "always-revoked" default (auto_expose_new_tables) does not silently break
-- new tables the same way.
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES
TO anon, authenticated, service_role;
@@ -0,0 +1,67 @@
-- Production-like user control: application-level user table (public.profiles)
-- auto-provisioned from auth.users, with admin-managed control fields.
-- 1. Control columns (inherit existing table-wide grants automatically)
ALTER TABLE public.profiles
ADD COLUMN IF NOT EXISTS role text NOT NULL DEFAULT 'user',
ADD COLUMN IF NOT EXISTS is_banned boolean NOT NULL DEFAULT false;
-- 2. Backfill profiles for existing auth users (e.g. the current test accounts)
INSERT INTO public.profiles (id, email)
SELECT id, email FROM auth.users
ON CONFLICT (id) DO NOTHING;
-- 3. Auto-provision a profile row whenever an auth user is created (signup/admin)
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
BEGIN
INSERT INTO public.profiles (id, email)
VALUES (new.id, new.email)
ON CONFLICT (id) DO NOTHING;
RETURN new;
END;
$$;
-- 4. Keep profile email in sync with auth.users email changes
CREATE OR REPLACE FUNCTION public.handle_user_email_change()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
BEGIN
IF new.email IS DISTINCT FROM old.email THEN
UPDATE public.profiles SET email = new.email WHERE id = new.id;
END IF;
RETURN new;
END;
$$;
DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
DROP TRIGGER IF EXISTS on_auth_user_email_change ON auth.users;
CREATE TRIGGER on_auth_user_email_change
AFTER UPDATE OF email ON auth.users
FOR EACH ROW EXECUTE FUNCTION public.handle_user_email_change();
-- 5. Users may update their own profile, but must NOT be able to escalate
-- their own role or unban themselves.
-- NOTE: a column-level REVOKE does NOT override a broad TABLE-level grant.
-- The earlier grant migration gave INSERT,UPDATE,DELETE on profiles to
-- anon/authenticated (table-wide => every column). So undo the table-level
-- write grants for user-facing roles, then re-grant UPDATE only on the
-- editable columns. Profiles are created via the trigger, so users need no
-- INSERT or DELETE. service_role (backend/admin) keeps full access and
-- bypasses RLS.
REVOKE INSERT, UPDATE, DELETE ON public.profiles FROM authenticated;
REVOKE INSERT, UPDATE, DELETE ON public.profiles FROM anon;
GRANT SELECT ON public.profiles TO anon, authenticated;
GRANT UPDATE (display_name, avatar_url) ON public.profiles TO authenticated;