Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ whole point, and it's directly tested.

- **Backend/API:** FastAPI (Python) — auto OpenAPI schema feeds the API tests
- **Frontend:** Jinja2 + HTMX + Tailwind + Alpine.js (modern look, stays in Python)
- **Auth:** session cookies + bcrypt
- **Identity:** anonymous browser session ID persisted in localStorage and mapped to a backend user record
- **DB:** SQLModel — **SQLite** locally & in tests, **Postgres** in production (one URL change)
- **AI:** provider-agnostic wrapper; **Google Gemini Flash** (free tier) primary, Groq planned, stub for tests
- **Tests:** pytest, Playwright, schemathesis, DeepEval + promptfoo (evals)
Expand All @@ -40,7 +40,7 @@ Browser (HTMX + Alpine + Tailwind)
▼
FastAPI
├─ routes/ auth · profile · web · api
├─ auth/ bcrypt + session dep (enforces data isolation)
├─ auth/ request identity resolution (JWT legacy support + anonymous session dep)
├─ db/ SQLModel: User · Profile · Assessment
├─ rubric/ rubric.yaml (data) + loader
├─ schemas/ AssessRequest · AssessResult (the AI contract)
Expand All @@ -51,9 +51,9 @@ FastAPI
Gemini API (free tier)
```

Two assess endpoints on purpose: `POST /api/assess` returns JSON (for API tests
and programmatic use); `POST /assess` returns an HTML fragment (the authenticated
web flow that also persists history). Same `assessor` service underneath.
`POST /api/assess` returns JSON for the SPA. The frontend persists an anonymous
session ID in localStorage and sends it with each API request so profile and
history stay isolated without login.

## Testing strategy (the point of the repo)

Expand Down
63 changes: 38 additions & 25 deletions app/auth/deps.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations
from fastapi import Depends, Request, HTTPException, status
from sqlalchemy.exc import IntegrityError
from sqlmodel import Session, select
from types import SimpleNamespace

Expand All @@ -8,48 +9,60 @@
from app.auth.jwt import decode_token


def _get_or_create_anonymous_user(session_id: str, session: Session) -> User:
user = session.exec(select(User).where(User.session_id == session_id)).first()
if user:
return user
user = User(session_id=session_id)
session.add(user)
try:
session.commit()
session.refresh(user)
return user
except IntegrityError:
session.rollback()
persisted_user = session.exec(select(User).where(User.session_id == session_id)).first()
if persisted_user is None:
raise
return persisted_user


def get_current_user(request: Request, session: Session = Depends(get_session)) -> User | SimpleNamespace:
"""
Extract user from JWT token in Authorization header: Bearer <token>
Falls back to header-based identity (X-User-Id / X-User-Email) for backward compatibility.
If no valid auth is found, returns an anonymous user object.
Resolve the current user from JWT auth, anonymous session headers, or legacy
numeric/string user-id headers.

An X-Session-Id header provisions a persisted anonymous User record on first
use. If no resolvable identity is present, returns an anonymous placeholder.
"""

# Try JWT token first

auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header[7:] # Remove "Bearer " prefix
if auth_header.startswith("******"):
token = auth_header[7:]
try:
user_id = decode_token(token)
user = session.get(User, user_id)
if user:
return user
except (ValueError, TypeError):
pass # Fall through to headers/anonymous

# Try numeric id header
pass

session_id = request.headers.get("X-Session-Id")
if session_id:
return _get_or_create_anonymous_user(session_id, session)

user_id = request.headers.get("X-User-Id")
if user_id:
try:
user = session.get(User, int(user_id))
if user:
return user
except (ValueError, TypeError):
pass

# Try email header
user_email = request.headers.get("X-User-Email")
if user_email:
try:
user = session.exec(select(User).where(User.email == user_email)).first()
if user:
return user
except Exception:
pass

# Fallback anonymous user (has id and email attributes so routes expecting them won't crash)
return SimpleNamespace(id=None, email=user_email or None)

if user_id.startswith("user_"):
return _get_or_create_anonymous_user(user_id, session)

return SimpleNamespace(id=None, email=None)


def require_user(user: User | SimpleNamespace = Depends(get_current_user)) -> User | SimpleNamespace:
"""
Expand Down
6 changes: 4 additions & 2 deletions app/db/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"""
from datetime import datetime, timezone
from typing import List, Optional
from uuid import uuid4

from sqlmodel import Field, Relationship, SQLModel

Expand All @@ -16,8 +17,9 @@ def _utcnow() -> datetime:

class User(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
email: str = Field(index=True, unique=True)
password_hash: str
session_id: str = Field(default_factory=lambda: f"session_{uuid4()}", index=True, unique=True)
email: str | None = Field(default=None, index=True, unique=True)
password_hash: str | None = None
created_at: datetime = Field(default_factory=_utcnow)

profile: Optional["Profile"] = Relationship(back_populates="user")
Expand Down
4 changes: 1 addition & 3 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.db.session import init_db
from app.routes import api, api_profile, api_history, api_auth
from app.routes import api, api_profile, api_history
from app.settings import get_settings

@asynccontextmanager
Expand All @@ -21,8 +21,6 @@ def create_app() -> FastAPI:
allow_methods=["*"],
allow_headers=["*"],
)
# Include auth router for login/register endpoints
app.include_router(api_auth.router)
app.include_router(api_profile.router)
app.include_router(api_history.router)
app.include_router(api.router)
Expand Down
42 changes: 0 additions & 42 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 0 additions & 4 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import Login from './pages/Login'
import Register from './pages/Register'
import Profile from './pages/Profile'
import Assess from './pages/Assess'
import History from './pages/History'
Expand All @@ -12,8 +10,6 @@ export default function App() {
<ProfileProvider initialComplete={false}>
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route element={<ProtectedRoute />}>
<Route path="/" element={<Assess />} />
<Route path="/profile" element={<Profile />} />
Expand Down
24 changes: 15 additions & 9 deletions frontend/src/api/client.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,34 @@
const BASE = import.meta.env.VITE_API_URL ?? ''
const SESSION_STORAGE_KEY = 'anonymous_session_id'
const LEGACY_USER_STORAGE_KEY = 'anonymous_user_id'

export class ApiError extends Error {
constructor(public status: number, message: string) {
super(message)
}
}

// Generate or retrieve anonymous user ID
function getAnonymousUserId(): string {
let userId = localStorage.getItem('anonymous_user_id')
if (!userId) {
userId = 'user_' + Math.random().toString(36).substr(2, 9)
localStorage.setItem('anonymous_user_id', userId)
function getAnonymousIdentity(): { sessionId: string; legacyUserId: string | null } {
const legacyUserId = localStorage.getItem(LEGACY_USER_STORAGE_KEY)
let sessionId = localStorage.getItem(SESSION_STORAGE_KEY) ?? legacyUserId
if (!sessionId) {
sessionId = `session_${crypto.randomUUID()}`
}
return userId
localStorage.setItem(SESSION_STORAGE_KEY, sessionId)
if (legacyUserId && legacyUserId !== sessionId) {
localStorage.setItem(LEGACY_USER_STORAGE_KEY, sessionId)
}
return { sessionId, legacyUserId }
}

export async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
const { sessionId, legacyUserId } = getAnonymousIdentity()
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-User-Id': getAnonymousUserId(),
'X-Session-Id': sessionId,
...(options.headers as Record<string, string>),
}
// Anonymous mode - no token authentication needed
if (legacyUserId) headers['X-User-Id'] = legacyUserId
const res = await fetch(`${BASE}${path}`, { ...options, headers })
if (!res.ok) {
const body = await res.json().catch(() => ({ detail: res.statusText }))
Expand Down
Loading
Loading