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