Secure the LexAI Foundation
Secure LexAI with PostgreSQL, JWT sessions, and role-based contract access.
Introduction
30 Second Summary
A contract workspace loses trust the moment one person can see another person's records. A sign-out button also means little if the old session still works.
In this project, you will complete Phase 1 inside the existing LexAI codebase by connecting its React frontend to FastAPI with PostgreSQL persistence. The finished foundation protects every contract through database-backed identity, server-enforced roles, and owner-filtered access.
What You'll Build
After signing in to LexAI, you see a protected contract workspace populated only with records the FastAPI backend authorizes for your account.
By the end of this project, you'll have:
- A database-backed identity flow where registration creates real users in PostgreSQL. Passwords are stored as Argon2 hashes. Signed JWT sessions stay inside HttpOnly cookies. Logout revokes each token on the server.
- Backend-enforced authorization that gives Legal Users only their own contract rows. Admin users can inspect all users plus all contracts from a dedicated oversight page.
- A repeatable Phase 1 check that proves the security boundary against the live API. You can confirm restart persistence. You can also produce the React production bundle.
- Secret Mission: Admin-controlled Legal User deactivation that makes FastAPI reject the disabled identity on its next protected request.
Are there any prerequisites?
You need the existing lexai/ repository on a Windows computer. The guide includes setup and verification for every unconfirmed local tool.
Before We Start
This checkpoint prevents Phase 1 from drifting into a replacement application. You are committing to update the existing lexai/ repository in place while future AI data stays linked to contracts.id in the same PostgreSQL database.
Align the Workspace and Database
You have chosen the existing lexai/ repository as the home for Phase 1. A dependable PostgreSQL foundation keeps every future capability connected to the same contract records.
Configuration drift makes local testing unreliable. This step creates one source of configuration with pinned dependencies. It also gives the database a persistent volume that survives container restarts.
In this step, get ready to:
- Protect local credentials with Git ignore rules.
- Install the pinned backend dependencies and frontend dependencies.
- Run the database plus both application services.
Protect the workspace configuration
Tracked example files document every required setting. Private .env copies hold your local credentials outside version control.
- Switch to Visual Studio Code.
- Open the existing lexai/ folder from your workspace.
- Replace the contents of .env.example with this configuration:
POSTGRES_USER=lexai
POSTGRES_PASSWORD=replace-with-a-local-password
POSTGRES_DB=lexai
What does this configuration do?
- The username identifies the local PostgreSQL account.
- The password placeholder shows where the private value belongs.
- The database name keeps Phase 1 data inside lexai.
- Save .env.example.
- Reopen .env.example from the file sidebar.
You should see the three PostgreSQL settings with their exact names.
Missing a PostgreSQL setting?
Confirm that the file is named .env.example. Check that each setting uses one equals sign.
Ask for help if the file contents keep changing. Help me check my root PostgreSQL environment template.
- Replace the contents of backend/.env.example with these backend settings:
APP_NAME=LexAI API
DATABASE_URL=postgresql+psycopg://lexai:replace-with-a-local-password@localhost:5432/lexai
JWT_SECRET_KEY=replace-with-a-long-random-secret
JWT_ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
COOKIE_SECURE=false
FRONTEND_ORIGIN=http://localhost:5173
VERIFY_API_URL=http://localhost:8000
How does the backend use these settings?
The database URL points FastAPI at the local PostgreSQL service. Its username plus password must match the root configuration.
The remaining values configure JWT sessions plus local browser access. The private signing value belongs only in backend/.env.
- Save backend/.env.example.
- Reopen backend/.env.example from the file sidebar.
You should see eight settings ending with the local verification URL.
Does the database URL look different?
Keep the postgresql+psycopg scheme. Confirm that the URL ends with the lexai database name.
Use the exact example before adding private values. Help me correct my LexAI backend environment template.
- Replace the contents of frontend/.env.example with this public API URL:
VITE_API_URL=http://localhost:8000
Why is this value public?
Vite exposes settings prefixed with VITE_ to browser code. This value is a local URL, so it contains no credential.
- Save frontend/.env.example.
- Confirm that the file contains one setting.
Is the frontend URL missing?
Confirm that the filename starts with a dot. Keep the setting name uppercase.
Use this prompt if the browser cannot read the setting. Help me troubleshoot the Vite API environment file.
- Replace the contents of .gitignore with these exclusions:
.env
**/.env
backend/.venv/
frontend/node_modules/
frontend/dist/
__pycache__/
*.pyc
.pytest_cache/
What stays outside Git?
The first two rules exclude every private environment file. The remaining rules exclude installed dependencies plus generated Python and frontend output.
- Save .gitignore.
- Confirm that the file lists eight exclusions.
Are private files still offered to Git?
Check that .gitignore sits directly inside lexai/. Confirm that the second rule starts with two asterisks.
Tracked files require extra care because ignore rules apply to untracked files. Help me find why my LexAI .env files are still visible to Git.
The next action creates files containing local credentials. Keep those values out of screenshots plus commits.
- Return to the PowerShell terminal at the existing lexai/ root.
- Create the three private environment files by running these commands:
Copy-Item .env.example .env
Copy-Item backend\.env.example backend\.env
Copy-Item frontend\.env.example frontend\.env
What do these commands create?
Each command copies a tracked template to its private local counterpart. The ignore rules keep those copies outside normal Git changes.
- Replace the root POSTGRES_PASSWORD placeholder with a local password.
- Use the same password inside backend/.env.
- Replace the JWT_SECRET_KEY placeholder with a long random secret.
- Save all three private environment files.
- Confirm that the three private files stay absent from the files Git offers to commit.
Did an environment copy fail?
Run the copy commands from the existing lexai/ root. Confirm that each corresponding .env.example file exists.
Keep private values out of your request for help. Help me diagnose a failed PowerShell environment-file copy without exposing credentials.
Install dependencies and start PostgreSQL
Pinned dependencies make the existing backend plus frontend reproducible. Docker Compose gives PostgreSQL a named volume so database files remain available after a container restart.
- Replace the contents of compose.yaml with this database service:
services:
db:
image: postgres:18.6-alpine
container_name: lexai-postgres
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
ports:
- "5432:5432"
volumes:
- lexai_postgres_data:/var/lib/postgresql
volumes:
lexai_postgres_data:
name: lexai_postgres_data
How does the database persist?
The db service runs postgres:18.6-alpine as lexai-postgres. Port 5432 makes it available to the local backend.
The named lexai_postgres_data volume stores PostgreSQL files at /var/lib/postgresql. Container restarts leave that data in place.
- Save compose.yaml.
- Confirm that the service name is db.
Does the Compose file show an error?
YAML indentation controls nesting. Keep each environment setting beneath environment.
Check the volume declaration at both the service level and top level. Help me fix the indentation in my LexAI compose.yaml file.
- Replace the contents of backend/requirements.txt with these pinned packages:
fastapi[standard]==0.142.2
uvicorn[standard]==0.54.0
sqlmodel==0.0.47
psycopg[binary]==3.3.6
pwdlib[argon2]==0.3.1
PyJWT==2.15.1
pydantic-settings==2.15.0
httpx==0.28.1
What does the backend need?
These packages provide the API server, database models, PostgreSQL driver, password hashing, signed tokens, settings loading, and live HTTP verification.
- Save backend/requirements.txt.
- Confirm that the file contains eight pinned dependency lines.
Does a dependency line look incomplete?
Keep each package on its own line. Preserve the extras inside square brackets.
Ask for a line-by-line comparison if installation fails. Help me compare my LexAI backend requirements with the pinned dependency list.
- Replace the contents of frontend/package.json with this package configuration:
{
"name": "lexai-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "19.3.0",
"react-dom": "19.3.0",
"react-router": "8.4.0"
},
"devDependencies": {
"@vitejs/plugin-react": "6.1.1",
"vite": "8.3.2"
}
}
What does the frontend configuration pin?
The dependencies provide the React interface plus browser routing. The scripts start development, create a production bundle, or preview that bundle.
- Save frontend/package.json.
- Confirm that the JSON closes with one final brace.
Does npm reject package.json?
JSON requires double quotes around every key. Check the comma after each entry except the last entry in an object.
Use this prompt to find a formatting mismatch. Help me locate invalid JSON in my LexAI frontend package file.
- Replace the contents of frontend/vite.config.js with this Vite configuration:
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({ plugins: [react()] });
What does the Vite plugin do?
The React plugin lets Vite process the existing JSX source. Exporting one configuration keeps the current frontend scaffold intact.
- Save frontend/vite.config.js.
- Confirm that the configuration calls react() once.
Does Vite reject the plugin?
Confirm that both import paths use double quotes. Check that package.json includes the matching plugin dependency.
Ask for help if the configuration still fails to load. Help me troubleshoot my LexAI Vite React plugin configuration.
- Replace the contents of frontend/index.html with this HTML entry point:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>LexAI | Contract Intelligence</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
How does the frontend start?
The root element receives the React application. The module script loads the existing src/main.jsx entry point.
- Save frontend/index.html.
- Confirm that the page contains one root element.
Does the frontend open as a blank page?
Confirm that the script path starts with a forward slash. Check that frontend/src/main.jsx already exists in the current application.
Use this prompt if the entry point remains unresolved. Help me trace the LexAI HTML entry point to src/main.jsx.
- Press the Windows key to open search.
- Type Docker Desktop to find Docker Desktop.
- Press Enter to start it.
- Wait until Docker Desktop reports that its engine is ready.
- Install the backend dependencies from a root PowerShell terminal by running:
cd backend
pip install "fastapi[standard]==0.142.2" "uvicorn[standard]==0.54.0" sqlmodel==0.0.47 "psycopg[binary]==3.3.6" "pwdlib[argon2]==0.3.1" PyJWT==2.15.1 pydantic-settings==2.15.0 httpx==0.28.1
What does this installation prepare?
The command moves into backend before installing the pinned API packages. Successful output confirms that Python can load the Phase 1 stack.
The first installation can spend some time downloading packages. Ongoing progress in PowerShell means the installer is still working.
- Keep this PowerShell terminal in the backend folder after installation completes.
Did the backend installation fail?
Confirm that the terminal uses Python 3.10 or newer. Check that the command retained the quoted package extras.
Environment-specific installation errors can vary. Help me diagnose my pinned LexAI backend dependency installation.
- Create another PowerShell terminal at the existing lexai/ root.
- Install the frontend dependencies by running:
cd frontend
npm install
What does npm install here?
The command reads the pinned frontend package file. It installs the local React, routing, and Vite dependencies inside frontend/node_modules/.
- Keep this PowerShell terminal in the frontend folder after installation completes.
- Confirm that frontend/node_modules/ now exists.
Did npm stop before completing?
Confirm that the terminal uses Node.js 22.22.0 or newer. Check that frontend/package.json contains valid JSON.
Share only the package error output when requesting help. Help me diagnose my LexAI npm installation.
Before you start the database, which container name plus volume name do you expect this configuration to use?
- Return to the root PowerShell terminal.
- Start PostgreSQL in the background and inspect its status by running:
docker compose up --detach
docker compose ps
What do these Docker commands prove?
The first command creates or starts the configured database service in the background. The second command reports its current container status.
The first startup may download the PostgreSQL image. Docker displays layer progress while that work continues.
You should see lexai-postgres running with local port 5432. The Compose configuration attaches the named lexai_postgres_data volume.
Is PostgreSQL missing from the status output?
Confirm that Docker Desktop reports its engine as ready. Run the commands from the folder containing compose.yaml.
A local port conflict can also stop the service. Help me diagnose why lexai-postgres is not running.
Connect FastAPI to PostgreSQL
The health endpoint must execute a real database query. A static success message could hide a broken connection, while select(Users) proves that FastAPI can reach the Phase 1 database.
- Create an empty backend/app/__init__.py file inside the existing backend.
- Create an empty backend/app/core/__init__.py file inside the existing backend.
- Confirm that both empty package marker files appear in the VS Code file sidebar.
- Create an empty backend/app/routers/__init__.py file inside the existing backend.
- Create an empty backend/app/scripts/__init__.py file inside the existing backend.
- Confirm that both new package marker files appear in the VS Code file sidebar.
- Replace the contents of backend/app/core/config.py with this settings loader:
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
app_name: str = "LexAI API"
database_url: str
jwt_secret_key: str
jwt_algorithm: str = "HS256"
access_token_expire_minutes: int = 30
cookie_secure: bool = False
frontend_origin: str = "http://localhost:5173"
verify_api_url: str = "http://localhost:8000"
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings()
What does the settings loader do?
- The settings model reads backend configuration from .env.
- Typed fields convert values such as the expiry duration plus cookie security flag.
- The cache creates one shared settings object for the running API process.
- Save backend/app/core/config.py.
- Confirm that settings = get_settings() is the final line.
Does settings initialization fail?
Confirm that backend/.env contains every required value. Keep the database password synchronized with the root .env file.
Do not include the private values in your help request. Help me diagnose my LexAI settings loader without exposing secrets.
- Create backend/app/models.py with the initial user table model:
from datetime import datetime, timezone
from sqlmodel import Field, SQLModel
ROLE_ADMIN = "admin"
ROLE_LEGAL_USER = "legal_user"
def utc_now() -> datetime:
return datetime.now(timezone.utc)
class Users(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
email: str = Field(index=True, unique=True)
full_name: str
hashed_password: str
role: str = Field(default=ROLE_LEGAL_USER, index=True)
is_active: bool = True
created_at: datetime = Field(default_factory=utc_now)
updated_at: datetime = Field(default_factory=utc_now)
Why define Users now?
SQLModel maps this class to the users table. The health endpoint queries that model so database connectivity is measured through a real table.
- Save backend/app/models.py.
- Confirm that the class name is exactly Users.
Does the model import fail?
Confirm that the import uses sqlmodel in lowercase. Check the indentation beneath Users.
Use this prompt for a focused comparison. Help me find the mismatch in my initial LexAI Users model.
- Replace the contents of backend/app/db.py with this database session layer:
from collections.abc import Generator
from typing import Annotated
from fastapi import Depends
from sqlmodel import Session, create_engine
from app import models
from app.core.config import settings
engine = create_engine(settings.database_url, pool_pre_ping=True)
def create_db_and_tables() -> None:
models.SQLModel.metadata.create_all(engine)
def get_session() -> Generator[Session, None, None]:
with Session(engine) as session:
yield session
SessionDep = Annotated[Session, Depends(get_session)]
How does the session layer work?
- The engine uses the private database URL loaded from backend settings.
- Pool pre-ping checks a connection before handing it to the API.
- The session dependency gives each request a managed database session.
- Save backend/app/db.py.
- Confirm that SessionDep is defined at the bottom of the file.
Does the engine reject the database URL?
Confirm that PostgreSQL is running. Check that both local environment files use the same database password.
Keep the password hidden when requesting help. Help me diagnose the LexAI SQLModel engine connection.
- Replace the contents of backend/app/main.py with this initial API application:
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from sqlmodel import select
from app.core.config import settings
from app.db import SessionDep, create_db_and_tables
from app.models import Users
create_db_and_tables()
app = FastAPI(title=settings.app_name)
app.add_middleware(
CORSMiddleware,
allow_origins=[settings.frontend_origin],
allow_credentials=True,
allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["Content-Type"],
)
@app.get("/api/health")
def health(session: SessionDep) -> dict[str, str]:
session.exec(select(Users)).first()
return {"api": "online", "database": "connected"}
What proves the database is connected?
Application startup creates missing tables from the current SQLModel metadata. Credentialed CORS allows the configured local frontend origin to send cookies later.
The health handler queries Users before returning success. A broken PostgreSQL connection prevents the success response.
- Save backend/app/main.py.
- Confirm that the health route ends with the connected response.
Does FastAPI fail during import?
Confirm that every Python file sits beneath backend/app/ with the package marker files in place. Check that the backend terminal remains inside backend.
Use the traceback without sharing environment values. Help me trace a LexAI FastAPI import failure.
The next two commands keep their terminals occupied while the development servers run. Leave both terminals active during the final check.
- Return to the backend PowerShell terminal from earlier.
- Start FastAPI by running:
uvicorn app.main:app
What is running now?
Uvicorn imports app.main:app from the backend folder. FastAPI creates the current tables before accepting local requests.
- Keep the backend terminal running.
- Confirm that the terminal reports a local server address without a startup traceback.
Did the API stop during startup?
Check the first traceback line that points into your project. Confirm that PostgreSQL remains running before restarting the API.
Share the traceback with private values removed. Help me diagnose why the LexAI API stops during startup.
- Return to the frontend PowerShell terminal from earlier.
- Start the existing React application by running:
npm run dev
What is running now?
The development script starts Vite for the existing frontend. The terminal reports the local address while the process remains active.
- Keep the frontend terminal running.
- Open http://localhost:5173 in a browser tab.
You should see the existing LexAI frontend served by Vite.
Does the frontend fail to load?
Confirm that npm run dev is running from frontend. Check the terminal for a source-file path that could not be resolved.
Ask for help with the non-secret terminal output. Help me diagnose why the existing LexAI frontend does not load.
Before you check the API, do you expect the health response to succeed if the database query cannot run?
- Open a new browser tab.
- Enter http://localhost:8000/api/health in the address bar.
You should see {"api":"online","database":"connected"}. That response proves the API completed a real query against the users table.
That is the foundation working: the existing frontend runs beside a FastAPI service connected to persistent PostgreSQL storage.
Use the second tab to compare every Phase 1 setup file. Keep the private .env files out of this comparison.
✔️ Awesome, I've got everything!
Your environment templates, pinned dependencies, database service, and initial health endpoint are aligned. Confirm that every changed file is saved.
ⓧ I'd like to double check the full code
POSTGRES_USER=lexai
POSTGRES_PASSWORD=replace-with-a-local-password
POSTGRES_DB=lexai
.env
**/.env
backend/.venv/
frontend/node_modules/
frontend/dist/
__pycache__/
*.pyc
.pytest_cache/
# LexAI Phase 1
This updates the existing LexAI repository in place. Phase 1 implements secure identity, role authorization, owner-filtered contract records, and a protected React workspace. It does not implement document upload, extraction, BM25, embeddings, FAISS, RRF, InLegalBERT, RAG, citations, summaries, risks, comparisons, or reminders.
## Final structure
```text
lexai/
|-- .env.example
|-- .gitignore
|-- compose.yaml
|-- README.md
|-- backend/
| |-- .env.example
| |-- requirements.txt
| `-- app/
| |-- __init__.py
| |-- main.py
| |-- db.py
| |-- models.py
| |-- schemas.py
| |-- dependencies.py
| |-- core/
| | |-- __init__.py
| | |-- config.py
| | `-- security.py
| |-- routers/
| | |-- __init__.py
| | |-- auth.py
| | |-- users.py
| | |-- contracts.py
| | `-- admin.py
| `-- scripts/
| |-- __init__.py
| |-- create_admin.py
| `-- verify_phase1.py
`-- frontend/
|-- .env.example
|-- package.json
|-- vite.config.js
|-- index.html
`-- src/
|-- main.jsx
|-- App.jsx
|-- api.js
|-- ProtectedRoute.jsx
|-- styles.css
`-- pages/
|-- LoginPage.jsx
|-- RegisterPage.jsx
|-- DashboardPage.jsx
`-- AdminPage.jsx
```
## Database schema
- `users`: identity, Argon2 hash, role, activation state, timestamps; email is unique.
- `contracts`: owner ID, title, file metadata, status, processing stage, checksum, storage URI, page count, JSON metadata text, and timestamps.
- `tokenrevocations`: revoked JWT `jti`, owning user, token expiry, and revocation time.
Future AI data belongs in child tables keyed to `contracts.id`, so document versions, pages, chunks, clauses, entities, obligations, reminders, embeddings, retrieval runs, reranking, RAG answers, evidence citations, summaries, risk findings, and comparisons can evolve independently.
## Dependencies
Backend: FastAPI 0.142.2, Uvicorn 0.54.0, SQLModel 0.0.47, Psycopg 3.3.6, pwdlib 0.3.1 with Argon2, PyJWT 2.15.1, pydantic-settings 2.15.0, HTTPX 0.28.1.
Frontend: React 19.3.0, React DOM 19.3.0, React Router 8.4.0, Vite 8.3.2, @vitejs/plugin-react 6.1.1.
## Environment files
From the existing project root:
```powershell
Copy-Item .env.example .env
Copy-Item backend\.env.example backend\.env
Copy-Item frontend\.env.example frontend\.env
```
Use the same PostgreSQL password in the root and backend files. Replace `JWT_SECRET_KEY` with a long random secret. Never commit any `.env` file.
Required variables:
- Root: `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`
- Backend: `APP_NAME`, `DATABASE_URL`, `JWT_SECRET_KEY`, `JWT_ALGORITHM`, `ACCESS_TOKEN_EXPIRE_MINUTES`, `COOKIE_SECURE`, `FRONTEND_ORIGIN`, `VERIFY_API_URL`
- Frontend: `VITE_API_URL`. This is a public URL, not a secret.
## Start PostgreSQL
```powershell
docker compose up --detach
docker compose ps
```
## Start FastAPI
```powershell
cd backend
pip install "fastapi[standard]==0.142.2" "uvicorn[standard]==0.54.0" sqlmodel==0.0.47 "psycopg[binary]==3.3.6" "pwdlib[argon2]==0.3.1" PyJWT==2.15.1 pydantic-settings==2.15.0 httpx==0.28.1
uvicorn app.main:app
```
## Start React
```powershell
cd frontend
npm install
npm run dev
```
Open `http://localhost:5173`.
## Register and log in
1. Open `/register` and create a Legal User.
2. Sign in at `/login`.
3. The backend sets `lexai_access_token` as an HttpOnly cookie. React does not store or read the JWT.
4. Select Log out to revoke the token in PostgreSQL and delete the cookie.
## Test Admin and Legal User
Create or promote an Admin from the backend folder:
```powershell
python -m app.scripts.create_admin
```
A Legal User sees only owned contracts and receives 403 from Admin endpoints. An Admin can open `/admin`, list authorized users, and inspect all contract records.
## Verify ownership and security
Keep PostgreSQL and FastAPI running, then execute:
```powershell
cd backend
python -m app.scripts.verify_phase1
```
The script creates temporary users and a temporary contract, verifies password hashing, JWT claims, logout revocation, 401, 403, Admin access, and User A versus User B isolation, then removes its rows.
## Verify database persistence
Create a contract record in the dashboard, then run:
```powershell
docker compose restart db
```
After PostgreSQL is available again, refresh the dashboard. The row must remain.
## Verify the React build
```powershell
cd frontend
npm run build
```
## Remaining configuration
- Set a strong `JWT_SECRET_KEY`.
- Keep `COOKIE_SECURE=false` only for local HTTP development. Set it to `true` behind production HTTPS.
- Keep the frontend and API on the same site for the current SameSite Strict cookie design. Reassess SameSite and CSRF controls before a cross-site deployment.
- Add Alembic before changing an already-populated shared schema in later phases.
services:
db:
image: postgres:18.6-alpine
container_name: lexai-postgres
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
ports:
- "5432:5432"
volumes:
- lexai_postgres_data:/var/lib/postgresql
volumes:
lexai_postgres_data:
name: lexai_postgres_data
APP_NAME=LexAI API
DATABASE_URL=postgresql+psycopg://lexai:replace-with-a-local-password@localhost:5432/lexai
JWT_SECRET_KEY=replace-with-a-long-random-secret
JWT_ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
COOKIE_SECURE=false
FRONTEND_ORIGIN=http://localhost:5173
VERIFY_API_URL=http://localhost:8000
fastapi[standard]==0.142.2
uvicorn[standard]==0.54.0
sqlmodel==0.0.47
psycopg[binary]==3.3.6
pwdlib[argon2]==0.3.1
PyJWT==2.15.1
pydantic-settings==2.15.0
httpx==0.28.1
The files backend/app/__init__.py, backend/app/core/__init__.py, backend/app/routers/__init__.py, and backend/app/scripts/__init__.py are empty.
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
app_name: str = "LexAI API"
database_url: str
jwt_secret_key: str
jwt_algorithm: str = "HS256"
access_token_expire_minutes: int = 30
cookie_secure: bool = False
frontend_origin: str = "http://localhost:5173"
verify_api_url: str = "http://localhost:8000"
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings()
from datetime import datetime, timezone
from sqlmodel import Field, SQLModel
ROLE_ADMIN = "admin"
ROLE_LEGAL_USER = "legal_user"
def utc_now() -> datetime:
return datetime.now(timezone.utc)
class Users(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
email: str = Field(index=True, unique=True)
full_name: str
hashed_password: str
role: str = Field(default=ROLE_LEGAL_USER, index=True)
is_active: bool = True
created_at: datetime = Field(default_factory=utc_now)
updated_at: datetime = Field(default_factory=utc_now)
from collections.abc import Generator
from typing import Annotated
from fastapi import Depends
from sqlmodel import Session, create_engine
from app import models
from app.core.config import settings
engine = create_engine(settings.database_url, pool_pre_ping=True)
def create_db_and_tables() -> None:
models.SQLModel.metadata.create_all(engine)
def get_session() -> Generator[Session, None, None]:
with Session(engine) as session:
yield session
SessionDep = Annotated[Session, Depends(get_session)]
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from sqlmodel import select
from app.core.config import settings
from app.db import SessionDep, create_db_and_tables
from app.models import Users
create_db_and_tables()
app = FastAPI(title=settings.app_name)
app.add_middleware(
CORSMiddleware,
allow_origins=[settings.frontend_origin],
allow_credentials=True,
allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["Content-Type"],
)
@app.get("/api/health")
def health(session: SessionDep) -> dict[str, str]:
session.exec(select(Users)).first()
return {"api": "online", "database": "connected"}
VITE_API_URL=http://localhost:8000
{
"name": "lexai-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "19.3.0",
"react-dom": "19.3.0",
"react-router": "8.4.0"
},
"devDependencies": {
"@vitejs/plugin-react": "6.1.1",
"vite": "8.3.2"
}
}
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({ plugins: [react()] });
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>LexAI | Contract Intelligence</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
Your existing workspace now has a persistent database plus a live connection check. Next, you will turn that foundation into database-backed identity and revocable JWT sessions.
Build Identity and JWT Sessions
Your local LexAI stack can reach PostgreSQL. The next boundary decides who can enter the application without exposing passwords or session tokens.
A connected application still exposes user data unless the backend controls password hashing, token issuance, cookie transport, expiration, and logout invalidation. In this step, FastAPI becomes the authority for every identity decision.
In this step, get ready to:
- Persist validated users plus revoked token identifiers in PostgreSQL.
- Issue signed JWT sessions through an HttpOnly cookie.
- Connect the React registration and login screens to the protected API.
Create the identity foundation
A secure identity record stores a one-way password hash. It also keeps authorization fields under backend control.
The revocation table gives logout a permanent server-side effect. A token listed there fails future protected requests even if its cookie still exists.
- In the VS Code Explorer sidebar, create models.py inside backend/app.
- Add the user roles plus the Users table by pasting this first section:
from datetime import datetime, timezone
from sqlmodel import Field, SQLModel
ROLE_ADMIN = "admin"
ROLE_LEGAL_USER = "legal_user"
def utc_now() -> datetime:
return datetime.now(timezone.utc)
class Users(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
email: str = Field(index=True, unique=True)
full_name: str
hashed_password: str
role: str = Field(default=ROLE_LEGAL_USER, index=True)
is_active: bool = True
created_at: datetime = Field(default_factory=utc_now)
updated_at: datetime = Field(default_factory=utc_now)
What Does This Model Do?
- The unique email field makes PostgreSQL reject duplicate normalized addresses.
- The hashed_password field stores the Argon2 result instead of the original password.
- The backend assigns legal_user as the default role.
- The is_active field lets every protected request reject a disabled identity.
- Add the token revocation model below the Users class:
class TokenRevocations(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
jti: str = Field(index=True, unique=True)
user_id: int = Field(foreign_key="users.id", index=True)
expires_at: datetime
revoked_at: datetime = Field(default_factory=utc_now)
Why Store Revoked Tokens?
Each JWT receives a unique jti. Logout stores that identifier so the backend can reject the same token before its expiry time.
- Save backend/app/models.py.
- Create schemas.py inside backend/app.
- Define the identity request and response shapes by pasting this code:
from pydantic import BaseModel, Field
class UserCreate(BaseModel):
email: str = Field(min_length=5, max_length=255)
full_name: str = Field(min_length=2, max_length=120)
password: str = Field(min_length=8, max_length=128)
class LoginRequest(BaseModel):
email: str = Field(min_length=5, max_length=255)
password: str = Field(min_length=8, max_length=128)
class UserRead(BaseModel):
id: int
email: str
full_name: str
role: str
is_active: bool
class LoginResponse(BaseModel):
message: str
user: UserRead
class Message(BaseModel):
message: str
What Do These Schemas Protect?
- The input schemas reject names, email addresses, and passwords outside the accepted lengths before persistence.
- The response schemas omit hashed_password so API responses cannot reveal password hashes.
- The public registration schema has no role field, which keeps role assignment under server control.
- Save backend/app/schemas.py.
- Create security.py inside backend/app/core.
- Add the password hashing foundation by pasting this first section:
from datetime import datetime, timedelta, timezone
from uuid import uuid4
import jwt
from pwdlib import PasswordHash
from app.core.config import settings
COOKIE_NAME = "lexai_access_token"
password_hash = PasswordHash.recommended()
DUMMY_HASH = password_hash.hash("lexai-dummy-password")
def hash_password(password: str) -> str:
return password_hash.hash(password)
def verify_password(password: str, hashed_password: str) -> bool:
return password_hash.verify(password, hashed_password)
How Does Password Hashing Work?
The PasswordHash.recommended() configuration uses Argon2 for one-way password storage. Login verifies a submitted password against the stored hash without recovering the original value.
The dummy hash gives an unknown email path a password verification task. This reduces timing differences between nonexistent users and incorrect passwords.
- Add the JWT creation and decoding functions below verify_password().
def create_access_token(subject: str) -> tuple[str, str, datetime]:
expires_at = datetime.now(timezone.utc) + timedelta(
minutes=settings.access_token_expire_minutes
)
jti = str(uuid4())
token = jwt.encode(
{"sub": subject, "jti": jti, "exp": expires_at},
settings.jwt_secret_key,
algorithm=settings.jwt_algorithm,
)
return token, jti, expires_at
def decode_access_token(token: str) -> dict:
return jwt.decode(
token,
settings.jwt_secret_key,
algorithms=[settings.jwt_algorithm],
)
What Goes Inside the JWT?
- The string sub claim identifies the database user.
- The random jti claim gives this specific token a revocable identifier.
- The exp claim gives the token a fixed expiry time.
- The configured HS256 algorithm signs the claims with the backend secret.
The next setting controls every local session signature. Treating it carefully now prevents anyone with the example value from forging a valid token.
- Switch back to backend/.env from the earlier setup.
- Replace the example value for JWT_SECRET_KEY with a long random secret generated locally.
- Save backend/.env without displaying or sharing its contents.
- Save backend/app/core/security.py.
Keep the Secret Local
Your existing .gitignore excludes every .env file. Keep the reusable variable name in backend/.env.example while the real signing value stays only in backend/.env.
Enforce protected sessions in FastAPI
A signed token proves that LexAI issued the claims. The protected dependency still has to check the user, expiry, activation state, and revocation record on every request.
- Create dependencies.py inside backend/app.
- Add the imports plus the authenticated request context by pasting this first section:
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Annotated
from fastapi import Cookie, Depends, HTTPException, status
from jwt.exceptions import InvalidTokenError
from sqlmodel import select
from app.core.security import decode_access_token
from app.db import SessionDep
from app.models import ROLE_ADMIN, TokenRevocations, Users
@dataclass
class AuthContext:
user: Users
jti: str
expires_at: datetime
Why Use an Authentication Context?
The AuthContext keeps the authenticated user beside the token identifier and expiry. Logout can therefore revoke the exact token used for the current request.
- Add the cookie decoding dependency below AuthContext.
def get_auth_context(
session: SessionDep,
lexai_access_token: Annotated[str | None, Cookie()] = None,
) -> AuthContext:
error = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
)
if lexai_access_token is None:
raise error
try:
payload = decode_access_token(lexai_access_token)
user_id = int(payload["sub"])
jti = str(payload["jti"])
expires_at = datetime.fromtimestamp(payload["exp"], timezone.utc)
except (InvalidTokenError, KeyError, TypeError, ValueError) as caught:
raise error from caught
revoked = session.exec(
select(TokenRevocations).where(TokenRevocations.jti == jti)
).first()
user = session.get(Users, user_id)
if revoked or user is None or not user.is_active:
raise error
return AuthContext(user=user, jti=jti, expires_at=expires_at)
What Does the Dependency Reject?
- A missing cookie receives a 401 response.
- A malformed or expired JWT receives the same 401 response.
- A revoked token receives a 401 response after its jti is found.
- A missing or inactive database user receives a 401 response.
- Add the reusable current-user and administrator dependencies at the bottom of backend/app/dependencies.py.
CurrentAuth = Annotated[AuthContext, Depends(get_auth_context)]
def get_current_user(auth: CurrentAuth) -> Users:
return auth.user
CurrentUser = Annotated[Users, Depends(get_current_user)]
def require_admin(current_user: CurrentUser) -> Users:
if current_user.role != ROLE_ADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin role required",
)
return current_user
AdminUser = Annotated[Users, Depends(require_admin)]
Why Define Reusable Dependencies?
Routes can request CurrentUser or AdminUser without duplicating cookie validation. This keeps the security boundary consistent across the API.
- Save backend/app/dependencies.py.
- Create auth.py inside backend/app/routers.
- Add the imports plus the cookie helper by pasting this first section:
from fastapi import APIRouter, HTTPException, Response, status
from sqlmodel import select
from app.core.config import settings
from app.core.security import (
COOKIE_NAME,
DUMMY_HASH,
create_access_token,
hash_password,
verify_password,
)
from app.db import SessionDep
from app.dependencies import CurrentAuth
from app.models import ROLE_LEGAL_USER, TokenRevocations, Users
from app.schemas import LoginRequest, LoginResponse, Message, UserCreate, UserRead
router = APIRouter(prefix="/api/auth", tags=["authentication"])
def normalize_email(email: str) -> str:
return email.strip().lower()
def set_auth_cookie(response: Response, token: str) -> None:
response.set_cookie(
key=COOKIE_NAME,
value=token,
max_age=settings.access_token_expire_minutes * 60,
path="/",
secure=settings.cookie_secure,
httponly=True,
samesite="strict",
)
Why Use an HttpOnly Cookie?
The cookie carries the JWT automatically with eligible requests. The httponly setting prevents frontend JavaScript from reading the token.
SameSite Strict limits cross-site cookie sending. The COOKIE_SECURE setting remains false for local HTTP development and must become true behind production HTTPS.
- Add the registration endpoint below set_auth_cookie().
@router.post("/register", response_model=UserRead, status_code=201)
def register(payload: UserCreate, session: SessionDep) -> UserRead:
email = normalize_email(payload.email)
if "@" not in email:
raise HTTPException(status_code=422, detail="Enter a valid email address")
if session.exec(select(Users).where(Users.email == email)).first():
raise HTTPException(status_code=409, detail="Email is already registered")
user = Users(
email=email,
full_name=payload.full_name.strip(),
hashed_password=hash_password(payload.password),
role=ROLE_LEGAL_USER,
)
session.add(user)
session.commit()
session.refresh(user)
return UserRead.model_validate(user)
What Does Registration Enforce?
- The email address is trimmed and converted to lowercase before lookup or storage.
- The unique lookup rejects an address already present in PostgreSQL.
- The password is hashed before the new Users row is committed.
- Every public registration receives the server-controlled legal_user role.
- Add the login endpoint below register().
@router.post("/login", response_model=LoginResponse)
def login(payload: LoginRequest, response: Response, session: SessionDep) -> LoginResponse:
user = session.exec(
select(Users).where(Users.email == normalize_email(payload.email))
).first()
if user is None:
verify_password(payload.password, DUMMY_HASH)
if user is None or not verify_password(payload.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
)
if not user.is_active:
raise HTTPException(status_code=403, detail="User account is inactive")
token, _, _ = create_access_token(str(user.id))
set_auth_cookie(response, token)
return LoginResponse(message="Login successful", user=UserRead.model_validate(user))
What Happens During Login?
FastAPI retrieves the normalized email and verifies the submitted password. An active user receives a signed JWT in the response cookie.
The response returns safe user fields to React. The token itself remains outside JavaScript.
- Add the logout endpoint below login().
@router.post("/logout", response_model=Message)
def logout(
response: Response,
session: SessionDep,
auth: CurrentAuth,
) -> Message:
session.add(
TokenRevocations(
jti=auth.jti,
user_id=auth.user.id,
expires_at=auth.expires_at,
)
)
session.commit()
response.delete_cookie(
key=COOKIE_NAME,
path="/",
secure=settings.cookie_secure,
httponly=True,
samesite="strict",
)
return Message(message="Logged out")
Why Revoke Before Deleting?
Logout commits the current jti to PostgreSQL before deleting the browser cookie. A copied or undeleted token therefore remains invalid on the server.
- Save backend/app/routers/auth.py.
- Create users.py inside backend/app/routers.
- Add the protected current-user endpoint by pasting this code:
from fastapi import APIRouter
from app.dependencies import CurrentUser
from app.schemas import UserRead
router = APIRouter(prefix="/api/users", tags=["users"])
@router.get("/me", response_model=UserRead)
def read_current_user(current_user: CurrentUser) -> UserRead:
return UserRead.model_validate(current_user)
What Proves a Session Is Valid?
The /api/users/me route only runs after CurrentUser accepts the cookie. Its response proves the JWT maps to an active database user.
- Save backend/app/routers/users.py.
- In backend/app/main.py, extend the router import to include auth and users.
- Register the identity routers above the health endpoint by adding these lines:
app.include_router(auth.router)
app.include_router(users.router)
What Does Router Registration Change?
Registering both routers exposes the authentication operations plus the protected current-user operation through the existing FastAPI application.
- Save backend/app/main.py.
- Return to the PowerShell terminal running FastAPI.
- Stop the current process by pressing Ctrl+C.
- Start FastAPI from the existing backend folder by running this command:
uvicorn app.main:app
What Does Startup Confirm?
FastAPI imports the new modules and runs create_db_and_tables(). A clean startup confirms Python can load the identity models, dependencies, and routers.
- Keep the FastAPI terminal running.
- Open a second PowerShell terminal in the existing lexai root.
- Confirm that SQLModel created both identity tables by running this command:
docker compose exec db psql -U lexai -d lexai -c "SELECT table_name FROM information_schema.tables WHERE table_schema='public' ORDER BY table_name;"
What Should the Table Query Show?
The output includes users and tokenrevocations. These rows confirm that the schema now exists inside the persistent PostgreSQL volume.
Identity Tables Missing?
Confirm that FastAPI started from the backend folder. Starting from another location can prevent the intended environment file from loading.
Check that backend/app/main.py still calls create_db_and_tables() after importing the models.
Ask for help with the exact traceback if startup fails. Help me diagnose why SQLModel did not create the users and tokenrevocations tables in my LexAI PostgreSQL database.
Connect React and verify the session lifecycle
The browser must send the session cookie with each API request. The frontend can use the returned user object while the JWT stays unreadable to JavaScript.
- Create api.js inside frontend/src.
- Add the shared credentialed API client by pasting this code:
const API_URL = import.meta.env.VITE_API_URL ?? "http://localhost:8000";
export async function api(path, options = {}) {
const headers = { ...(options.headers ?? {}) };
if (typeof options.body === "string") headers["Content-Type"] = "application/json";
const response = await fetch(`${API_URL}${path}`, {
...options,
headers,
credentials: "include",
});
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.detail ?? "Request failed");
return data;
}
What Does the API Client Do?
- The VITE_API_URL value points browser requests at the local FastAPI server.
- The JSON header is added when the request body is a string.
- The credentials: "include" option lets the browser send and receive the session cookie.
- API error details become JavaScript errors that the forms can display.
- Save frontend/src/api.js.
- Create the pages folder inside frontend/src if it is missing.
- Create LoginPage.jsx inside frontend/src/pages.
- Add the login form by pasting this component:
import { useState } from "react";
import { api } from "../api.js";
export default function LoginPage({ onLogin }) {
const [form, setForm] = useState({ email: "", password: "" });
const [error, setError] = useState("");
async function submit(event) {
event.preventDefault();
setError("");
try {
const result = await api("/api/auth/login", { method: "POST", body: JSON.stringify(form) });
onLogin(result.user);
} catch (caught) {
setError(caught.message);
}
}
return (
<main className="auth-layout">
<section className="brand"><span>Indian corporate contracts</span><h1>LexAI</h1><p>Secure access to evidence-ready contract intelligence.</p></section>
<section className="auth-card"><h2>Sign in</h2><form onSubmit={submit}>
<label>Email<input type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} required /></label>
<label>Password<input type="password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} required /></label>
{error && <p className="error">{error}</p>}<button>Sign in</button>
</form><p><a href="/register">Create a Legal User account</a></p></section>
</main>
);
}
How Does Login Handle the JWT?
The form sends only the email and password to FastAPI. A successful response supplies safe user data to onLogin while the browser stores the HttpOnly cookie.
- Save frontend/src/pages/LoginPage.jsx.
- Create RegisterPage.jsx inside frontend/src/pages.
- Add the Legal User registration form by pasting this component:
import { useState } from "react";
import { api } from "../api.js";
export default function RegisterPage() {
const [form, setForm] = useState({ full_name: "", email: "", password: "" });
const [error, setError] = useState("");
async function submit(event) {
event.preventDefault();
setError("");
try {
await api("/api/auth/register", { method: "POST", body: JSON.stringify(form) });
window.location.assign("/login");
} catch (caught) {
setError(caught.message);
}
}
return (
<main className="auth-layout">
<section className="brand"><span>Phase 1 foundation</span><h1>Controlled access</h1><p>Public registration always creates a Legal User.</p></section>
<section className="auth-card"><h2>Register</h2><form onSubmit={submit}>
<label>Full name<input value={form.full_name} onChange={(e) => setForm({ ...form, full_name: e.target.value })} required /></label>
<label>Email<input type="email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} required /></label>
<label>Password<input type="password" minLength="8" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} required /></label>
{error && <p className="error">{error}</p>}<button>Create account</button>
</form><p><a href="/login">Back to login</a></p></section>
</main>
);
}
Why Is Registration Always a Legal User?
The form collects no role value. FastAPI assigns legal_user so a public request cannot create an administrator.
- Save frontend/src/pages/RegisterPage.jsx.
- Return to the browser-based API documentation at http://127.0.0.1:8000/docs.
- Expand POST /api/auth/register.
- Select Try it out.
- Enter a new local email address, a full name, and a password containing at least eight characters.
- Select Execute.
You should see status 201 with an id, normalized email, full_name, legal_user role, and active state. The password and hash remain absent.
- Return to the second PowerShell terminal in the existing lexai root.
- Inspect the stored password value by running this command:
docker compose exec db psql -U lexai -d lexai -c "SELECT email, hashed_password FROM users;"
What Should the Password Query Show?
The new row shows the normalized email beside a value that starts with $argon2. Your original password must not appear anywhere in the result.
Before you test the session, do you expect the protected current-user route to succeed before login or after login?
- In the API documentation, execute GET /api/users/me before logging in.
You should see status 401 because the browser has no valid lexai_access_token cookie.
- Expand POST /api/auth/login.
- Select Try it out.
- Enter the email address and password used for registration.
- Select Execute.
- Execute GET /api/users/me again.
You should now see status 200 with the registered user. The browser sends the HttpOnly cookie without exposing the JWT in the response body.
Before you log out, do you think deleting the browser cookie alone would invalidate a copied JWT?
- Execute POST /api/auth/logout.
- Execute GET /api/users/me one final time.
You should see status 401. That result confirms the session no longer passes the protected dependency.
- Confirm that logout persisted a revocation by running this command from the existing lexai root:
docker compose exec db psql -U lexai -d lexai -c "SELECT jti, user_id, expires_at, revoked_at FROM tokenrevocations;"
What Proves Logout Is Server-Enforced?
The query returns a revocation row for the logged-out session. FastAPI checks that table during every protected request, so deleting the cookie is only one part of logout.
Session Check Not Behaving as Expected?
Confirm that every API operation runs in the same browser session. Cookies belong to the browser context that received the login response.
Check that backend/.env uses COOKIE_SECURE=false for local HTTP development.
Ask for help with the status sequence if the result differs. Help me diagnose my LexAI registration, JWT cookie, current-user, logout, and token revocation flow.
That is the identity boundary working end to end. LexAI now stores Argon2 hashes, issues signed cookie sessions, checks database state on protected requests, and records every logout revocation.
Enforce Roles and Contract Ownership
Your FastAPI identity layer can now determine who is making each request. The next boundary decides which PostgreSQL records that identity may access.
Authentication alone would let one Legal User discover another user's contracts. This step adds role-based access control for Admin routes. It also adds object-level authorization for every contract query.
In this step, get ready to:
- Add the extensible contracts table with server-controlled ownership.
- Build protected contract endpoints with owner-filtered queries.
- Restrict user lists and overview metrics to Admin users.
Add the contract model and schemas
A contract needs a permanent owner before future document processing begins. The public request schema excludes ownership and role fields, so the backend remains the authority for both values.
- Return to the existing LexAI window in Visual Studio Code.
- Select backend/app/models.py in the Explorer sidebar.
- Find the TokenRevocations class.
- Insert the contract model directly above that class by copying this code:
class Contracts(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
owner_user_id: int = Field(foreign_key="users.id", index=True)
title: str = Field(index=True)
original_filename: str | None = None
stored_filename: str | None = None
content_type: str | None = None
file_size_bytes: int | None = None
storage_uri: str | None = None
sha256: str | None = Field(default=None, index=True)
status: str = Field(default="draft", index=True)
processing_stage: str = Field(default="not_started", index=True)
source_page_count: int | None = None
metadata_json: str = "{}"
created_at: datetime = Field(default_factory=utc_now)
updated_at: datetime = Field(default_factory=utc_now)
What does the contract model protect?
- The owner_user_id foreign key ties every contract to a real row in users.
- The nullable file fields reserve metadata without pretending that document upload exists in Phase 1.
- The status and processing_stage defaults describe a newly created metadata record.
- The metadata_json field leaves room for extensible key-value metadata.
- Save backend/app/models.py.
- Return to the PowerShell terminal running the API.
- Stop the current API process with Ctrl+C.
- Restart the API from the existing backend folder by running this command:
uvicorn app.main:app
What does this restart prove?
Application startup calls create_db_and_tables(). A successful restart means SQLModel could register the new table definition against the connected database.
You should see the API start without a model or database error. The local database now has the contracts table alongside users and tokenrevocations.
Does the API fail during startup?
Check that the new class sits after Users. Confirm that it remains above TokenRevocations.
Check that the foreign key is exactly users.id. The table name comes from the existing Users model.
Help me diagnose my contract model startup error.
Request schemas control which values a client may submit. Response schemas expose the stored row and calculate readiness from visible contracts.
- Select backend/app/schemas.py in the Explorer sidebar.
- Append the public contract creation schema by copying this code:
class ContractCreate(BaseModel):
title: str = Field(min_length=1, max_length=200)
original_filename: str | None = Field(default=None, max_length=255)
content_type: str | None = Field(default=None, max_length=120)
file_size_bytes: int | None = Field(default=None, ge=0)
metadata: dict[str, str] = Field(default_factory=dict)
Why is ownership missing?
The request accepts a title and permitted file metadata. The route gets ownership from CurrentUser, which prevents a public form from assigning a contract to another user.
- Save backend/app/schemas.py.
- Confirm the new class has no editor-reported syntax error.
Is ContractCreate underlined?
Confirm that BaseModel and Field are still imported at the top of the file.
Help me fix my ContractCreate schema.
- Append the contract response schema beneath ContractCreate by copying this code:
class ContractRead(BaseModel):
id: int
owner_user_id: int
title: str
original_filename: str | None
content_type: str | None
file_size_bytes: int | None
storage_uri: str | None
sha256: str | None
status: str
processing_stage: str
source_page_count: int | None
metadata: dict[str, str]
created_at: datetime
updated_at: datetime
What does the response expose?
The response reports ownership and stored processing state. It returns parsed JSON metadata through metadata.
- Save backend/app/schemas.py.
- Confirm ContractRead ends with the two timestamp fields.
Are the timestamp types unresolved?
Check that the existing file still imports datetime from the datetime module.
Help me fix the ContractRead timestamp annotations.
- Append the readiness response schema beneath ContractRead by copying this code:
class ContractReadiness(BaseModel):
scope: str
total_contracts: int
with_file_metadata: int
without_file_metadata: int
status_counts: dict[str, int]
What does readiness summarize?
The schema reports the caller's scope and counts from actual visible rows. It separates records with filenames from metadata-only records.
- Save backend/app/schemas.py.
- Confirm the schema contains one scope field and four count fields.
Does the readiness schema look incomplete?
Check that status_counts uses dict[str, int]. This lets the API report counts for stored status values.
Help me compare my ContractReadiness schema.
- Append the Admin overview schema beneath ContractReadiness by copying this code:
class AdminOverview(BaseModel):
active_users: int
total_users: int
total_contracts: int
What does the overview report?
The overview exposes three database-derived totals. The Admin router calculates them from real users and contracts.
- Save backend/app/schemas.py.
- Restart the API with the same Uvicorn command to load the completed schemas.
The API should start cleanly again. Your data model now describes contract ownership without accepting an owner or role from the client.
✔️ Awesome, I've got everything!
Great. Double-check that both files are saved before you build the protected routes.
ⓧ I'd like to double check the full code
from datetime import datetime, timezone
from sqlmodel import Field, SQLModel
ROLE_ADMIN = "admin"
ROLE_LEGAL_USER = "legal_user"
def utc_now() -> datetime:
return datetime.now(timezone.utc)
class Users(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
email: str = Field(index=True, unique=True)
full_name: str
hashed_password: str
role: str = Field(default=ROLE_LEGAL_USER, index=True)
is_active: bool = True
created_at: datetime = Field(default_factory=utc_now)
updated_at: datetime = Field(default_factory=utc_now)
class Contracts(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
owner_user_id: int = Field(foreign_key="users.id", index=True)
title: str = Field(index=True)
original_filename: str | None = None
stored_filename: str | None = None
content_type: str | None = None
file_size_bytes: int | None = None
storage_uri: str | None = None
sha256: str | None = Field(default=None, index=True)
status: str = Field(default="draft", index=True)
processing_stage: str = Field(default="not_started", index=True)
source_page_count: int | None = None
metadata_json: str = "{}"
created_at: datetime = Field(default_factory=utc_now)
updated_at: datetime = Field(default_factory=utc_now)
class TokenRevocations(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
jti: str = Field(index=True, unique=True)
user_id: int = Field(foreign_key="users.id", index=True)
expires_at: datetime
revoked_at: datetime = Field(default_factory=utc_now)
from datetime import datetime
from pydantic import BaseModel, Field
class UserCreate(BaseModel):
email: str = Field(min_length=5, max_length=255)
full_name: str = Field(min_length=2, max_length=120)
password: str = Field(min_length=8, max_length=128)
class LoginRequest(BaseModel):
email: str = Field(min_length=5, max_length=255)
password: str = Field(min_length=8, max_length=128)
class UserRead(BaseModel):
id: int
email: str
full_name: str
role: str
is_active: bool
class LoginResponse(BaseModel):
message: str
user: UserRead
class Message(BaseModel):
message: str
class ContractCreate(BaseModel):
title: str = Field(min_length=1, max_length=200)
original_filename: str | None = Field(default=None, max_length=255)
content_type: str | None = Field(default=None, max_length=120)
file_size_bytes: int | None = Field(default=None, ge=0)
metadata: dict[str, str] = Field(default_factory=dict)
class ContractRead(BaseModel):
id: int
owner_user_id: int
title: str
original_filename: str | None
content_type: str | None
file_size_bytes: int | None
storage_uri: str | None
sha256: str | None
status: str
processing_stage: str
source_page_count: int | None
metadata: dict[str, str]
created_at: datetime
updated_at: datetime
class ContractReadiness(BaseModel):
scope: str
total_contracts: int
with_file_metadata: int
without_file_metadata: int
status_counts: dict[str, int]
class AdminOverview(BaseModel):
active_users: int
total_users: int
total_contracts: int
Build owner-filtered contract routes
The contract router centralizes the visibility rule. Legal Users receive a query filtered by their database ID, while Admin users receive the full query.
- Create backend/app/routers/contracts.py from the Explorer sidebar.
- Add the imports and router definition by copying this code:
import json
from fastapi import APIRouter, HTTPException
from sqlmodel import select
from app.db import SessionDep
from app.dependencies import CurrentUser
from app.models import ROLE_ADMIN, Contracts
from app.schemas import ContractCreate, ContractRead, ContractReadiness
router = APIRouter(prefix="/api/contracts", tags=["contracts"])
What does this setup connect?
The router uses the existing session dependency and authenticated user dependency. It imports the contract model plus the three new schemas.
- Save backend/app/routers/contracts.py.
- Confirm the file contains the /api/contracts prefix.
Are the imports unresolved?
Confirm that Contracts exists in models.py. Confirm that all three contract schemas exist in schemas.py.
Help me fix the contracts router imports.
- Append the shared visibility query beneath the router definition by copying this code:
def visible_contracts(session: SessionDep, current_user: CurrentUser) -> list[Contracts]:
statement = select(Contracts)
if current_user.role != ROLE_ADMIN:
statement = statement.where(Contracts.owner_user_id == current_user.id)
return session.exec(statement).all()
Where is ownership enforced?
Every Legal User query adds an owner filter before execution. Admin users retain the unfiltered statement, so they can inspect all contract rows.
- Save backend/app/routers/contracts.py.
- Confirm the owner comparison uses current_user.id.
Does the filter use the wrong identity?
The comparison must use Contracts.owner_user_id == current_user.id. A request value must never control this filter.
Help me review my contract ownership filter.
- Append the response conversion helper beneath visible_contracts() by copying this code:
def to_read(contract: Contracts) -> ContractRead:
return ContractRead(
id=contract.id,
owner_user_id=contract.owner_user_id,
title=contract.title,
original_filename=contract.original_filename,
content_type=contract.content_type,
file_size_bytes=contract.file_size_bytes,
storage_uri=contract.storage_uri,
sha256=contract.sha256,
status=contract.status,
processing_stage=contract.processing_stage,
source_page_count=contract.source_page_count,
metadata=json.loads(contract.metadata_json),
created_at=contract.created_at,
updated_at=contract.updated_at,
)
Why use a conversion helper?
The database stores metadata as text. The helper decodes it into the dictionary promised by ContractRead.
- Save backend/app/routers/contracts.py.
- Confirm every response field comes from the supplied contract row.
Does metadata fail to decode?
Check that the file imports json. Confirm the helper reads contract.metadata_json.
Help me debug the to_read metadata conversion.
- Append the protected creation endpoint beneath to_read() by copying this code:
@router.post("", response_model=ContractRead, status_code=201)
def create_contract(
payload: ContractCreate,
session: SessionDep,
current_user: CurrentUser,
) -> ContractRead:
metadata_json = json.dumps(payload.metadata)
if len(metadata_json) > 10000:
raise HTTPException(status_code=422, detail="Metadata is too large")
contract = Contracts(
owner_user_id=current_user.id,
title=payload.title.strip(),
original_filename=payload.original_filename,
content_type=payload.content_type,
file_size_bytes=payload.file_size_bytes,
metadata_json=metadata_json,
)
session.add(contract)
session.commit()
session.refresh(contract)
return to_read(contract)
How is contract creation controlled?
- The endpoint serializes only the permitted metadata supplied by ContractCreate.
- The route sets owner_user_id from the authenticated user.
- The size check stops an oversized metadata string before persistence.
- The commit and refresh return the database-generated contract ID.
- Save backend/app/routers/contracts.py.
- Confirm the Contracts constructor contains no public owner value.
Can the client still set an owner?
Check that ContractCreate has no owner field. Check that the route assigns current_user.id directly.
Help me verify that contract ownership stays server-controlled.
- Append the protected list endpoint beneath create_contract() by copying this code:
@router.get("", response_model=list[ContractRead])
def list_contracts(
session: SessionDep,
current_user: CurrentUser,
) -> list[ContractRead]:
return [to_read(contract) for contract in visible_contracts(session, current_user)]
Why reuse visible_contracts?
The list endpoint cannot accidentally skip the shared policy. Every returned row has already passed the role-aware visibility query.
- Save backend/app/routers/contracts.py.
- Confirm the endpoint calls visible_contracts() before converting rows.
Does the list expose every row?
Confirm the endpoint does not execute a new unfiltered select(Contracts) query. It must reuse visible_contracts().
Help me trace the list endpoint's authorization path.
- Append the readiness endpoint beneath list_contracts() by copying this code:
@router.get("/readiness", response_model=ContractReadiness)
def readiness(
session: SessionDep,
current_user: CurrentUser,
) -> ContractReadiness:
contracts = visible_contracts(session, current_user)
counts: dict[str, int] = {}
for contract in contracts:
counts[contract.status] = counts.get(contract.status, 0) + 1
with_file = sum(1 for contract in contracts if contract.original_filename)
return ContractReadiness(
scope="all_contracts" if current_user.role == ROLE_ADMIN else "owned_contracts",
total_contracts=len(contracts),
with_file_metadata=with_file,
without_file_metadata=len(contracts) - with_file,
status_counts=counts,
)
Why are the counts trustworthy?
The endpoint counts the same visible rows returned by the list endpoint. Legal Users receive owned totals, while Admin users receive system-wide totals.
- Save backend/app/routers/contracts.py.
- Confirm total_contracts comes from the length of the filtered collection.
Do readiness totals disagree with the list?
Confirm both endpoints call visible_contracts() with the same session and current user.
Help me compare my readiness totals with my visible contract list.
- Append the protected single-record endpoint beneath readiness() by copying this code:
@router.get("/{contract_id}", response_model=ContractRead)
def read_contract(
contract_id: int,
session: SessionDep,
current_user: CurrentUser,
) -> ContractRead:
contract = session.get(Contracts, contract_id)
allowed = contract and (
current_user.role == ROLE_ADMIN or contract.owner_user_id == current_user.id
)
if not allowed:
raise HTTPException(status_code=404, detail="Contract not found")
return to_read(contract)
Why return 404 for an inaccessible row?
The route checks the role or owner before returning the record. The same response covers missing rows and records owned by somebody else, so the API does not reveal another user's contract.
- Save backend/app/routers/contracts.py.
- Confirm the authorization check appears before to_read(contract).
Can another user read the row?
Check that the ownership comparison uses contract.owner_user_id == current_user.id. Confirm that a failed check raises 404.
Help me review the read_contract authorization condition.
The new router remains inactive until the application includes it. Registering it also exposes the endpoints in the API's generated documentation.
- Select backend/app/main.py in the Explorer sidebar.
- Update the router import so it matches this line:
from app.routers import admin, auth, contracts, users
What does this import prepare?
The application can now reference both new router modules. The next edit registers their routes with FastAPI.
- Find the existing router registration lines in backend/app/main.py.
- Replace that registration group with this complete group:
app.include_router(auth.router)
app.include_router(users.router)
app.include_router(contracts.router)
app.include_router(admin.router)
What does registration change?
FastAPI now mounts the identity, contract, and Admin routes in one application. The Admin module is added in the next substep.
- Save backend/app/main.py.
- Keep the API stopped until admin.py exists.
Is admin.py unresolved?
That import becomes valid as soon as you create backend/app/routers/admin.py in the next substep.
Help me check my main.py router registration.
✔️ Awesome, I've got everything!
Your owner-filtered router is complete. Keep the files saved while you add the Admin boundary.
ⓧ I'd like to double check the full code
import json
from fastapi import APIRouter, HTTPException
from sqlmodel import select
from app.db import SessionDep
from app.dependencies import CurrentUser
from app.models import ROLE_ADMIN, Contracts
from app.schemas import ContractCreate, ContractRead, ContractReadiness
router = APIRouter(prefix="/api/contracts", tags=["contracts"])
def visible_contracts(session: SessionDep, current_user: CurrentUser) -> list[Contracts]:
statement = select(Contracts)
if current_user.role != ROLE_ADMIN:
statement = statement.where(Contracts.owner_user_id == current_user.id)
return session.exec(statement).all()
def to_read(contract: Contracts) -> ContractRead:
return ContractRead(
id=contract.id,
owner_user_id=contract.owner_user_id,
title=contract.title,
original_filename=contract.original_filename,
content_type=contract.content_type,
file_size_bytes=contract.file_size_bytes,
storage_uri=contract.storage_uri,
sha256=contract.sha256,
status=contract.status,
processing_stage=contract.processing_stage,
source_page_count=contract.source_page_count,
metadata=json.loads(contract.metadata_json),
created_at=contract.created_at,
updated_at=contract.updated_at,
)
@router.post("", response_model=ContractRead, status_code=201)
def create_contract(
payload: ContractCreate,
session: SessionDep,
current_user: CurrentUser,
) -> ContractRead:
metadata_json = json.dumps(payload.metadata)
if len(metadata_json) > 10000:
raise HTTPException(status_code=422, detail="Metadata is too large")
contract = Contracts(
owner_user_id=current_user.id,
title=payload.title.strip(),
original_filename=payload.original_filename,
content_type=payload.content_type,
file_size_bytes=payload.file_size_bytes,
metadata_json=metadata_json,
)
session.add(contract)
session.commit()
session.refresh(contract)
return to_read(contract)
@router.get("", response_model=list[ContractRead])
def list_contracts(
session: SessionDep,
current_user: CurrentUser,
) -> list[ContractRead]:
return [to_read(contract) for contract in visible_contracts(session, current_user)]
@router.get("/readiness", response_model=ContractReadiness)
def readiness(
session: SessionDep,
current_user: CurrentUser,
) -> ContractReadiness:
contracts = visible_contracts(session, current_user)
counts: dict[str, int] = {}
for contract in contracts:
counts[contract.status] = counts.get(contract.status, 0) + 1
with_file = sum(1 for contract in contracts if contract.original_filename)
return ContractReadiness(
scope="all_contracts" if current_user.role == ROLE_ADMIN else "owned_contracts",
total_contracts=len(contracts),
with_file_metadata=with_file,
without_file_metadata=len(contracts) - with_file,
status_counts=counts,
)
@router.get("/{contract_id}", response_model=ContractRead)
def read_contract(
contract_id: int,
session: SessionDep,
current_user: CurrentUser,
) -> ContractRead:
contract = session.get(Contracts, contract_id)
allowed = contract and (
current_user.role == ROLE_ADMIN or contract.owner_user_id == current_user.id
)
if not allowed:
raise HTTPException(status_code=404, detail="Contract not found")
return to_read(contract)
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from sqlmodel import select
from app.core.config import settings
from app.db import SessionDep, create_db_and_tables
from app.models import Users
from app.routers import admin, auth, contracts, users
create_db_and_tables()
app = FastAPI(title=settings.app_name)
app.add_middleware(
CORSMiddleware,
allow_origins=[settings.frontend_origin],
allow_credentials=True,
allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["Content-Type"],
)
app.include_router(auth.router)
app.include_router(users.router)
app.include_router(contracts.router)
app.include_router(admin.router)
@app.get("/api/health")
def health(session: SessionDep) -> dict[str, str]:
session.exec(select(Users)).first()
return {"api": "online", "database": "connected"}
Add Admin access and verify isolation
Admin access uses a dependency that rejects every non-Admin user before route logic runs. The Admin endpoints still calculate their responses from database rows.
- Create backend/app/routers/admin.py from the Explorer sidebar.
- Add the Admin-only overview and user-list routes by copying this code:
from fastapi import APIRouter
from sqlmodel import select
from app.db import SessionDep
from app.dependencies import AdminUser
from app.models import Contracts, Users
from app.schemas import AdminOverview, UserRead
router = APIRouter(prefix="/api/admin", tags=["admin"])
@router.get("/overview", response_model=AdminOverview)
def overview(session: SessionDep, current_admin: AdminUser) -> AdminOverview:
users = session.exec(select(Users)).all()
contracts = session.exec(select(Contracts)).all()
return AdminOverview(
active_users=sum(1 for user in users if user.is_active),
total_users=len(users),
total_contracts=len(contracts),
)
@router.get("/users", response_model=list[UserRead])
def list_users(session: SessionDep, current_admin: AdminUser) -> list[UserRead]:
return [UserRead.model_validate(user) for user in session.exec(select(Users)).all()]
Where is the Admin boundary?
Both routes require AdminUser. That dependency runs require_admin() before either query executes.
The overview reads the real user and contract tables. A Legal User receives 403 before seeing those totals.
- Save backend/app/routers/admin.py.
- Confirm both route functions include an AdminUser parameter.
Can a Legal User reach an Admin query?
Confirm the parameter type is AdminUser. Using CurrentUser here would authenticate the caller without enforcing the Admin role.
Help me audit my Admin route dependencies.
Public registration always creates a Legal User. The local promotion script creates an Admin or upgrades an existing user without exposing role selection through a public endpoint.
- Create backend/app/scripts/create_admin.py from the Explorer sidebar.
- Add the script imports by copying this code:
from getpass import getpass
from sqlmodel import Session, select
from app.core.security import hash_password
from app.db import create_db_and_tables, engine
from app.models import ROLE_ADMIN, Users, utc_now
Why use the existing security helpers?
The script sends Admin passwords through the same Argon2 hashing helper as registration. It also uses the existing SQLModel engine and timestamp function.
- Save backend/app/scripts/create_admin.py.
- Confirm the password helper import is hash_password.
Is the security import unresolved?
Check that the path is app.core.security. The helper already exists from the identity step.
Help me fix the create_admin imports.
- Complete the script beneath the imports by copying this code:
def main() -> None:
create_db_and_tables()
email = input("Admin email: ").strip().lower()
full_name = input("Admin full name: ").strip()
password = getpass("Admin password, at least 8 characters: ")
if "@" not in email or not full_name or len(password) < 8:
raise SystemExit("Valid email, full name, and 8-character password required")
with Session(engine) as session:
user = session.exec(select(Users).where(Users.email == email)).first()
if user:
user.full_name = full_name
user.hashed_password = hash_password(password)
user.role = ROLE_ADMIN
user.is_active = True
user.updated_at = utc_now()
else:
user = Users(
email=email,
full_name=full_name,
hashed_password=hash_password(password),
role=ROLE_ADMIN,
)
session.add(user)
session.commit()
print(f"Admin ready: {email}")
if __name__ == "__main__":
main()
How does promotion work?
An existing email is updated to the Admin role and reactivated. A new email creates an active Admin with a hashed password.
The script runs locally from the backend folder. No public API accepts a role value.
- Save backend/app/scripts/create_admin.py.
- Open a new PowerShell terminal in Visual Studio Code.
- Move into the existing backend folder if the new terminal opened in the lexai folder.
- Create or promote an Admin by running this command:
python -m app.scripts.create_admin
What prompts should you expect?
- The script asks for Admin email: first.
- It asks for Admin full name: next.
- It hides input at Admin password, at least 8 characters:.
- Enter the Admin email you want to reuse for testing: your Admin email.
- Enter your Admin's full name.
- Enter a password with at least eight characters.
You should see a confirmation beginning with Admin ready: followed by the normalized email. Your local database now has an active Admin identity.
Does the Admin script fail?
Confirm the terminal is inside the existing backend folder. Confirm PostgreSQL is still running.
Help me troubleshoot the create_admin script.
- Return to the API terminal.
- Start the completed application by running this command:
uvicorn app.main:app
What is active now?
The running application now includes identity routes, owner-filtered contract routes, and Admin-only routes. Startup also confirms the new router modules import successfully.
You should see the API start without an import error. Keep this terminal running during the authorization check.
Does Uvicorn report an import error?
Check that contracts.py and admin.py are both inside backend/app/routers.
Help me debug the completed router imports.
The Swagger UI at the default documentation path can send requests directly to the running API. Its browser session keeps the same local HttpOnly cookie between requests.
- Open the local API base address http://localhost:8000 in your browser.
- Append /docs to the address.
- Expand POST /api/auth/register.
- Click Try it out.
- Choose a unique email for User A: your User A email.
- Enter a full name for User A.
- Enter a password with at least eight characters that you can safely re-enter.
- Click Execute.
The response should show status 201. The returned role should be legal_user.
- Repeat the registration request with a different email for User B: your User B email.
- Expand POST /api/auth/login.
- Click Try it out.
- Enter your User A email.
- Enter User A's password.
- Click Execute.
The login response should show status 200. The browser now holds User A's HttpOnly cookie.
- Expand POST /api/contracts.
- Click Try it out.
- Set the request title to Phase 1 ownership test.
- Click Execute.
- Record the returned ID here: your User A contract ID.
The response should show status 201. Its owner_user_id should match User A's ID.
- Expand POST /api/auth/logout.
- Click Try it out.
- Click Execute.
- Return to POST /api/auth/login.
- Enter your User B email.
- Enter User B's password.
- Click Execute.
Before you send User B's read request, do you expect the API to reveal whether User A's contract exists?
- Expand GET /api/contracts/{contract_id}.
- Click Try it out.
- Enter your User A contract ID.
- Click Execute.
You should see 404 with a contract-not-found detail. This is the intended ownership boundary.
- Expand GET /api/contracts.
- Click Try it out.
- Click Execute.
User B should receive an empty JSON array. User A's record stays hidden from both the list and the single-record route.
- Expand GET /api/admin/users.
- Click Try it out.
- Click Execute.
You should see 403 because User B has the Legal User role. The Admin query never runs for this caller.
- Log User B out through POST /api/auth/logout.
- Log in through POST /api/auth/login with your Admin email.
- Run GET /api/admin/users again.
- Run GET /api/contracts as the Admin.
- Run GET /api/admin/overview as the Admin.
You should see successful responses for all three Admin requests. The contract list should include User A's row, while the overview totals should come from the real database.
Do the authorization results differ?
Confirm each login completed before the next protected request. The browser must hold the cookie for the identity you intend to test.
Confirm User A's contract ID was copied from the creation response. Confirm User B uses a different registered email.
Help me debug the LexAI ownership and role checks.
✔️ Awesome, I've got everything!
That is the core boundary proven. Legal Users see owned records, while Admin users can inspect authorized system-wide data.
ⓧ I'd like to double check the full code
from fastapi import APIRouter
from sqlmodel import select
from app.db import SessionDep
from app.dependencies import AdminUser
from app.models import Contracts, Users
from app.schemas import AdminOverview, UserRead
router = APIRouter(prefix="/api/admin", tags=["admin"])
@router.get("/overview", response_model=AdminOverview)
def overview(session: SessionDep, current_admin: AdminUser) -> AdminOverview:
users = session.exec(select(Users)).all()
contracts = session.exec(select(Contracts)).all()
return AdminOverview(
active_users=sum(1 for user in users if user.is_active),
total_users=len(users),
total_contracts=len(contracts),
)
@router.get("/users", response_model=list[UserRead])
def list_users(session: SessionDep, current_admin: AdminUser) -> list[UserRead]:
return [UserRead.model_validate(user) for user in session.exec(select(Users)).all()]
from getpass import getpass
from sqlmodel import Session, select
from app.core.security import hash_password
from app.db import create_db_and_tables, engine
from app.models import ROLE_ADMIN, Users, utc_now
def main() -> None:
create_db_and_tables()
email = input("Admin email: ").strip().lower()
full_name = input("Admin full name: ").strip()
password = getpass("Admin password, at least 8 characters: ")
if "@" not in email or not full_name or len(password) < 8:
raise SystemExit("Valid email, full name, and 8-character password required")
with Session(engine) as session:
user = session.exec(select(Users).where(Users.email == email)).first()
if user:
user.full_name = full_name
user.hashed_password = hash_password(password)
user.role = ROLE_ADMIN
user.is_active = True
user.updated_at = utc_now()
else:
user = Users(
email=email,
full_name=full_name,
hashed_password=hash_password(password),
role=ROLE_ADMIN,
)
session.add(user)
session.commit()
print(f"Admin ready: {email}")
if __name__ == "__main__":
main()
Your API now enforces role and ownership boundaries against real PostgreSQL rows. Next up, you will turn those protected responses into a user-specific React workspace and an Admin oversight view.
Finish the Protected React Workspace
Your FastAPI backend now identifies each caller. It filters PostgreSQL contract records by role and owner.
The browser still needs to carry that security boundary into every route. In this step, React will show only authorized API data while the JWT remains inaccessible to JavaScript.
In this step, get ready to:
- Build the protected contract dashboard with real API data.
- Add the Admin oversight page with database-backed metrics.
- Verify protected routes and backend-first logout.
Build the protected contract dashboard
The dashboard must treat the API as its source of truth. Its contract rows and readiness counts come from the owner-filtered backend routes you already secured.
- In the Visual Studio Code file sidebar, create DashboardPage.jsx inside frontend/src/pages.
- Build the dashboard as one component by adding the following three regions in order:
import { useEffect, useState } from "react";
import { api } from "../api.js";
export default function DashboardPage({ user, onLogout }) {
const [contracts, setContracts] = useState([]);
const [summary, setSummary] = useState(null);
const [form, setForm] = useState({ title: "", original_filename: "" });
const [error, setError] = useState("");
async function loadWorkspace() {
try {
const [rows, readiness] = await Promise.all([
api("/api/contracts"),
api("/api/contracts/readiness"),
]);
setContracts(rows);
setSummary(readiness);
setError("");
} catch (caught) {
setError(caught.message);
}
}
What does this region do?
- The state values hold the authorized contract rows and readiness summary returned by the API.
- The loadWorkspace() helper requests both resources in parallel.
- A failed request stores its message for the dashboard error panel.
- Continue inside the component by adding the loading and mutation functions below loadWorkspace().
useEffect(() => {
let ignore = false;
Promise.all([api("/api/contracts"), api("/api/contracts/readiness")])
.then(([rows, readiness]) => {
if (!ignore) { setContracts(rows); setSummary(readiness); }
})
.catch((caught) => { if (!ignore) setError(caught.message); });
return () => { ignore = true; };
}, []);
async function createContract(event) {
event.preventDefault();
await api("/api/contracts", {
method: "POST",
body: JSON.stringify({
title: form.title,
original_filename: form.original_filename || null,
metadata: {},
}),
});
setForm({ title: "", original_filename: "" });
await loadWorkspace();
}
async function logout() {
await api("/api/auth/logout", { method: "POST" });
onLogout();
}
How do these actions stay secure?
- The effect loads contracts and readiness when the protected page mounts.
- The create request sends permitted metadata only. The backend assigns the owner.
- The logout function waits for server-side token revocation before clearing React user state.
- Complete DashboardPage by adding this interface below logout().
return (
<main className="shell">
<header><div><span className="eyebrow">Contract intelligence workspace</span><h1>LexAI</h1></div><div className="actions"><span className="badge">{user.role.replace("_", " ")}</span>{user.role === "admin" && <a href="/admin">Admin</a>}<button className="secondary" onClick={logout}>Log out</button></div></header>
<section className="hero"><div><span className="eyebrow">Authenticated</span><h2>{user.full_name}</h2><p>{user.email}</p></div><strong>{summary?.total_contracts ?? 0} authorized contracts</strong></section>
<section className="two-column">
<article className="panel"><h3>Add a Phase 1 contract record</h3><p className="muted">This stores metadata only. Document upload begins in a later phase.</p><form onSubmit={createContract}><label>Contract title<input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} required /></label><label>Original filename, optional<input value={form.original_filename} onChange={(e) => setForm({ ...form, original_filename: e.target.value })} /></label><button>Create record</button></form></article>
<article className="panel"><h3>Readiness summary</h3>{summary && <><p>Scope: {summary.scope.replace("_", " ")}</p><p>With file metadata: {summary.with_file_metadata}</p><p>Without file metadata: {summary.without_file_metadata}</p></>}</article>
</section>
<section className="panel"><h3>Authorized contracts</h3>{contracts.length === 0 ? <p className="empty">No contract records yet.</p> : <div className="contract-grid">{contracts.map((contract) => <article className="contract" key={contract.id}><span>#{contract.id}</span><h4>{contract.title}</h4><p>{contract.original_filename || "No document attached"}</p><small>{contract.status} | owner {contract.owner_user_id}</small></article>)}</div>}</section>
<section className="later"><strong>Later phases</strong><p>Upload, extraction, clauses, retrieval, embeddings, FAISS, reranking, RAG, citations, summaries, risks, comparison, and reminders are not implemented yet.</p></section>
{error && <p className="error">{error}</p>}
</main>
);
}
What will the dashboard show?
- The identity banner displays the authenticated user's name and role.
- The contract grid renders only records returned by the protected API.
- The empty state states the truth when the authorized list has no rows.
- The final notice keeps document intelligence features outside Phase 1.
- Save frontend/src/pages/DashboardPage.jsx.
- Confirm DashboardPage.jsx is listed beside the existing authentication pages in frontend/src/pages.
Seeing an error in DashboardPage?
Check that the import uses ../api.js. Confirm that all three code regions remain inside DashboardPage.
Check the closing braces around createContract() and logout().
Help me compare my dashboard component with the required LexAI structure.
✔️ Awesome, I've got everything!
Your contract workspace is now saved with real API loading and a truthful empty state.
ⓧ I'd like to double check the full code
import { useEffect, useState } from "react";
import { api } from "../api.js";
export default function DashboardPage({ user, onLogout }) {
const [contracts, setContracts] = useState([]);
const [summary, setSummary] = useState(null);
const [form, setForm] = useState({ title: "", original_filename: "" });
const [error, setError] = useState("");
async function loadWorkspace() {
try {
const [rows, readiness] = await Promise.all([
api("/api/contracts"),
api("/api/contracts/readiness"),
]);
setContracts(rows);
setSummary(readiness);
setError("");
} catch (caught) {
setError(caught.message);
}
}
useEffect(() => {
let ignore = false;
Promise.all([api("/api/contracts"), api("/api/contracts/readiness")])
.then(([rows, readiness]) => {
if (!ignore) { setContracts(rows); setSummary(readiness); }
})
.catch((caught) => { if (!ignore) setError(caught.message); });
return () => { ignore = true; };
}, []);
async function createContract(event) {
event.preventDefault();
await api("/api/contracts", {
method: "POST",
body: JSON.stringify({
title: form.title,
original_filename: form.original_filename || null,
metadata: {},
}),
});
setForm({ title: "", original_filename: "" });
await loadWorkspace();
}
async function logout() {
await api("/api/auth/logout", { method: "POST" });
onLogout();
}
return (
<main className="shell">
<header><div><span className="eyebrow">Contract intelligence workspace</span><h1>LexAI</h1></div><div className="actions"><span className="badge">{user.role.replace("_", " ")}</span>{user.role === "admin" && <a href="/admin">Admin</a>}<button className="secondary" onClick={logout}>Log out</button></div></header>
<section className="hero"><div><span className="eyebrow">Authenticated</span><h2>{user.full_name}</h2><p>{user.email}</p></div><strong>{summary?.total_contracts ?? 0} authorized contracts</strong></section>
<section className="two-column">
<article className="panel"><h3>Add a Phase 1 contract record</h3><p className="muted">This stores metadata only. Document upload begins in a later phase.</p><form onSubmit={createContract}><label>Contract title<input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} required /></label><label>Original filename, optional<input value={form.original_filename} onChange={(e) => setForm({ ...form, original_filename: e.target.value })} /></label><button>Create record</button></form></article>
<article className="panel"><h3>Readiness summary</h3>{summary && <><p>Scope: {summary.scope.replace("_", " ")}</p><p>With file metadata: {summary.with_file_metadata}</p><p>Without file metadata: {summary.without_file_metadata}</p></>}</article>
</section>
<section className="panel"><h3>Authorized contracts</h3>{contracts.length === 0 ? <p className="empty">No contract records yet.</p> : <div className="contract-grid">{contracts.map((contract) => <article className="contract" key={contract.id}><span>#{contract.id}</span><h4>{contract.title}</h4><p>{contract.original_filename || "No document attached"}</p><small>{contract.status} | owner {contract.owner_user_id}</small></article>)}</div>}</section>
<section className="later"><strong>Later phases</strong><p>Upload, extraction, clauses, retrieval, embeddings, FAISS, reranking, RAG, citations, summaries, risks, comparison, and reminders are not implemented yet.</p></section>
{error && <p className="error">{error}</p>}
</main>
);
}
What does this reference contain?
This is the complete dashboard file for Phase 1. It combines API loading with metadata-only creation and backend-first logout.
Add the Admin oversight page
The Admin page uses the same protected client. Backend authorization still decides whether its users and metrics can be returned.
- In the Visual Studio Code file sidebar, create AdminPage.jsx inside frontend/src/pages.
- Add the complete Admin oversight component by pasting this code:
import { useEffect, useState } from "react";
import { api } from "../api.js";
export default function AdminPage() {
const [users, setUsers] = useState([]);
const [contracts, setContracts] = useState([]);
const [overview, setOverview] = useState(null);
const [error, setError] = useState("");
useEffect(() => {
let ignore = false;
Promise.all([api("/api/admin/users"), api("/api/contracts"), api("/api/admin/overview")])
.then(([userRows, contractRows, metrics]) => {
if (!ignore) { setUsers(userRows); setContracts(contractRows); setOverview(metrics); }
})
.catch((caught) => { if (!ignore) setError(caught.message); });
return () => { ignore = true; };
}, []);
return (
<main className="shell"><header><div><span className="eyebrow">Admin authorization required</span><h1>LexAI oversight</h1></div><a href="/dashboard">Dashboard</a></header>
{overview && <section className="metrics"><article><strong>{overview.active_users}</strong><span>Active users</span></article><article><strong>{overview.total_users}</strong><span>Total users</span></article><article><strong>{overview.total_contracts}</strong><span>Total contracts</span></article></section>}
<section className="two-column"><article className="panel"><h3>Authorized users</h3>{users.map((user) => <p key={user.id}>{user.full_name} | {user.role} | {user.is_active ? "active" : "inactive"}</p>)}</article><article className="panel"><h3>All contracts</h3>{contracts.length === 0 ? <p className="empty">No contract records yet.</p> : contracts.map((contract) => <p key={contract.id}>#{contract.id} {contract.title} | owner {contract.owner_user_id}</p>)}</article></section>
{error && <p className="error">{error}</p>}
</main>
);
}
What does this page load?
- The Admin users request returns authorized identity records.
- The contract request returns every row because the backend recognizes the Admin role.
- The overview request supplies active-user and contract totals from PostgreSQL.
- The route guard prevents a Legal User from rendering this page.
- Save frontend/src/pages/AdminPage.jsx.
- Confirm AdminPage.jsx is listed in frontend/src/pages.
Admin page showing an editor error?
Check that the API import points to ../api.js. Confirm that the Promise.all() response order matches users, contracts, and overview.
Help me find the problem in my AdminPage component.
✔️ Awesome, I've got everything!
The Admin oversight page is ready to display authorized users and database-backed metrics.
ⓧ I'd like to double check the full code
import { useEffect, useState } from "react";
import { api } from "../api.js";
export default function AdminPage() {
const [users, setUsers] = useState([]);
const [contracts, setContracts] = useState([]);
const [overview, setOverview] = useState(null);
const [error, setError] = useState("");
useEffect(() => {
let ignore = false;
Promise.all([api("/api/admin/users"), api("/api/contracts"), api("/api/admin/overview")])
.then(([userRows, contractRows, metrics]) => {
if (!ignore) { setUsers(userRows); setContracts(contractRows); setOverview(metrics); }
})
.catch((caught) => { if (!ignore) setError(caught.message); });
return () => { ignore = true; };
}, []);
return (
<main className="shell"><header><div><span className="eyebrow">Admin authorization required</span><h1>LexAI oversight</h1></div><a href="/dashboard">Dashboard</a></header>
{overview && <section className="metrics"><article><strong>{overview.active_users}</strong><span>Active users</span></article><article><strong>{overview.total_users}</strong><span>Total users</span></article><article><strong>{overview.total_contracts}</strong><span>Total contracts</span></article></section>}
<section className="two-column"><article className="panel"><h3>Authorized users</h3>{users.map((user) => <p key={user.id}>{user.full_name} | {user.role} | {user.is_active ? "active" : "inactive"}</p>)}</article><article className="panel"><h3>All contracts</h3>{contracts.length === 0 ? <p className="empty">No contract records yet.</p> : contracts.map((contract) => <p key={contract.id}>#{contract.id} {contract.title} | owner {contract.owner_user_id}</p>)}</article></section>
{error && <p className="error">{error}</p>}
</main>
);
}
What does this reference contain?
This is the complete Admin page for Phase 1. Its visible records remain controlled by the backend Admin dependencies.
Run and verify the protected workspace
Both route components now satisfy the imports in App.jsx. You can start Vite beside the running API and test the complete browser flow.
- In a Windows PowerShell window positioned at the existing lexai root, start FastAPI by running:
cd backend
uvicorn app.main:app
What does this command start?
The first line enters the existing backend folder. The uvicorn process then serves app.main:app.
- Leave the FastAPI PowerShell window running.
FastAPI did not start?
Confirm that the PowerShell window started at the existing lexai root. Check that the PostgreSQL container from the previous step is still running.
Help me diagnose why the LexAI FastAPI server is not starting.
- Create a second terminal at the existing lexai root in your code editor.
- Start the React development server by running:
cd frontend
npm run dev
What does this command start?
The development script starts Vite with the existing React application. The browser client uses VITE_API_URL to reach FastAPI.
- Leave the Vite terminal running.
Vite did not start?
Confirm that node_modules exists inside frontend. Check the terminal for a filename that identifies a missing import or unmatched JSX delimiter.
Help me troubleshoot the LexAI Vite startup error.
Before you test the protected route, do you expect a private browser session to show the dashboard or the login page?
- Start a private browsing window using your browser menu.
- Enter http://localhost:5173 in the address bar.
- Change the URL path to /dashboard.
You are redirected to /login. This proves ProtectedRoute blocks an unauthenticated dashboard visit.
- Select Create a Legal User account.
- Enter LexAI Legal User in the Full name field.
- Enter your local Legal User email in the Email field.
- Enter Phase1TestPassword! in the Password field.
- Select Create account.
The registration request creates a Legal User. You are returned to the login page.
- Enter your local Legal User email in the Email field.
- Enter Phase1TestPassword! in the Password field.
- Select Sign in.
You see your name and Legal User role on the protected dashboard. The empty contract panel says No contract records yet.
Why does the JWT stay out of React?
The existing api helper sends requests with credentials: "include". The browser attaches the HttpOnly cookie without exposing its value to React.
The login response gives React the safe user object. The token remains under browser cookie controls.
- Change the URL path to /admin.
You are redirected to /dashboard. This proves the client route guard keeps a Legal User out of the Admin screen.
Before you create a contract, predict how the authorized count changes when the API returns the new row.
- Enter Phase 1 Contract in the Contract title field.
- Enter phase1-contract.pdf in the Original filename, optional field.
- Select Create record.
You see the new contract row with its backend-assigned owner ID. The readiness summary reports one authorized contract with file metadata.
The Admin page needs an Admin identity created through the existing backend script. The script promotes an existing email or creates a new active Admin.
- Choose your local Admin email for the upcoming Admin email prompt.
- Choose LexAI Admin for the upcoming full name prompt.
- Choose Phase1AdminPassword! for the upcoming password prompt.
- In another terminal at the existing lexai root, start the Admin script by running:
cd backend
python -m app.scripts.create_admin
What does this script do?
The script hashes the supplied password before storage. It assigns the server-controlled admin role to the selected user.
The password prompt stays hidden while you type. A successful run confirms the Admin email is ready.
That Admin account can now exercise the backend routes that return all users and contracts.
- Return to the Legal User dashboard in your browser.
- Select Log out.
The backend revokes the current JWT before React clears its user state. You are returned to the login page.
- Enter your local Admin email in the Email field.
- Enter Phase1AdminPassword! in the Password field.
- Select Sign in.
- Select Admin in the dashboard header.
You see active-user and total-user metrics from PostgreSQL. You also see the Legal User's contract with its owner ID.
- Select Dashboard.
- Select Log out.
- Sign in again with your local Legal User email.
Before the final refresh, do you expect the new contract row to remain and the readiness total to match the visible list?
- Refresh the Legal User dashboard.
You see Phase 1 Contract in the authorized list. The readiness total matches the single visible row.
Your protected React workspace now reflects real identity and contract data from PostgreSQL. Next, you will turn the Phase 1 acceptance criteria into repeatable integration checks.
Verify the Phase 1 Boundary
Your protected React workspace now communicates with FastAPI through database-backed identity rules. Security checks performed once through the interface do not provide repeatable evidence.
This step turns the Phase 1 acceptance criteria into an automated HTTPX test against the live API. You will also prove that PostgreSQL preserves a visible contract during a database restart.
In this step, get ready to:
- Verify Argon2 hashing, JWT claims, session revocation, role enforcement, and owner isolation.
- Confirm that the named Docker volume preserves a visible contract during a PostgreSQL restart.
- Build the production frontend and document the final Phase 1 boundary.
Automate the security checks
A dependable authorization test needs separate identities with independent cookie sessions. The verification script creates temporary User A, User B, and Admin identities against the real database.
- Return to the existing LexAI workspace in Visual Studio Code.
- Select the existing backend/app/scripts folder in the Explorer sidebar.
- Use the Explorer file control to create verify_phase1.py with the exact contents shown in the second tab below.
✔️ Awesome, I've got everything!
- Confirm that backend/app/scripts/verify_phase1.py contains the complete verification flow.
ⓧ I'd like to double check the full code
from uuid import uuid4
import httpx
import jwt
from sqlmodel import Session, select
from app.core.config import settings
from app.core.security import COOKIE_NAME, hash_password
from app.db import engine
from app.models import Contracts, ROLE_ADMIN, TokenRevocations, Users
suffix = uuid4().hex[:10]
email_a = f"phase1-a-{suffix}@example.com"
email_b = f"phase1-b-{suffix}@example.com"
email_admin = f"phase1-admin-{suffix}@example.com"
password = "Phase1TestPassword!"
contract_title = f"Phase 1 ownership test {suffix}"
def expect(response: httpx.Response, status_code: int, label: str) -> None:
assert response.status_code == status_code, (
f"{label}: expected {status_code}, got {response.status_code}: {response.text}"
)
def cleanup() -> None:
with Session(engine) as session:
users = session.exec(select(Users).where(Users.email == email_a)).all()
users += session.exec(select(Users).where(Users.email == email_b)).all()
users += session.exec(select(Users).where(Users.email == email_admin)).all()
for user in users:
for contract in session.exec(
select(Contracts).where(Contracts.owner_user_id == user.id)
).all():
session.delete(contract)
for revocation in session.exec(
select(TokenRevocations).where(TokenRevocations.user_id == user.id)
).all():
session.delete(revocation)
session.delete(user)
session.commit()
def main() -> None:
cleanup()
base_url = settings.verify_api_url
try:
expect(httpx.get(f"{base_url}/api/users/me"), 401, "unauthenticated API")
with httpx.Client(base_url=base_url, timeout=10.0) as user_a, httpx.Client(
base_url=base_url, timeout=10.0
) as user_b, httpx.Client(base_url=base_url, timeout=10.0) as admin:
for client, email, name in (
(user_a, email_a, "Phase One User A"),
(user_b, email_b, "Phase One User B"),
):
expect(
client.post(
"/api/auth/register",
json={"email": email, "full_name": name, "password": password},
),
201,
f"register {email}",
)
with Session(engine) as session:
stored_a = session.exec(select(Users).where(Users.email == email_a)).one()
assert stored_a.hashed_password != password
assert stored_a.hashed_password.startswith("$argon2")
session.add(
Users(
email=email_admin,
full_name="Phase One Admin",
hashed_password=hash_password(password),
role=ROLE_ADMIN,
)
)
session.commit()
expect(user_a.post("/api/auth/login", json={"email": email_a, "password": password}), 200, "login A")
token = user_a.cookies.get(COOKIE_NAME)
assert token
payload = jwt.decode(
token,
settings.jwt_secret_key,
algorithms=[settings.jwt_algorithm],
)
assert payload["sub"] and payload["jti"] and payload["exp"]
expect(user_a.get("/api/users/me"), 200, "current user A")
created = user_a.post(
"/api/contracts",
json={"title": contract_title, "metadata": {"test": "phase1"}},
)
expect(created, 201, "create contract A")
contract_id = created.json()["id"]
expect(user_a.get("/api/contracts/readiness"), 200, "readiness A")
expect(user_b.post("/api/auth/login", json={"email": email_b, "password": password}), 200, "login B")
expect(user_b.get(f"/api/contracts/{contract_id}"), 404, "B cannot read A contract")
assert all(item["id"] != contract_id for item in user_b.get("/api/contracts").json())
expect(user_b.get("/api/admin/users"), 403, "Legal User admin boundary")
expect(admin.post("/api/auth/login", json={"email": email_admin, "password": password}), 200, "admin login")
expect(admin.get("/api/admin/users"), 200, "admin users")
expect(admin.get(f"/api/contracts/{contract_id}"), 200, "admin contract access")
expect(user_a.post("/api/auth/logout"), 200, "logout A")
expect(user_a.get("/api/users/me"), 401, "revoked session")
print("PHASE 1 VERIFIED")
finally:
cleanup()
if __name__ == "__main__":
main()
What does this script verify?
- The first request proves that protected identity access returns 401 without an authenticated cookie.
- The direct database query proves that the stored password starts with $argon2.
- The decoded token proves that sub, jti, and exp claims are present.
- The independent clients prove owner isolation, Admin access, logout revocation, and cleanup of temporary rows.
- Save backend/app/scripts/verify_phase1.py.
- Keep PostgreSQL running in its existing container.
- Keep FastAPI running in its existing terminal.
Before you run this check, consider whether three independent cookie sessions should receive the same permissions.
- Run the automated boundary check from the existing lexai folder in PowerShell by running these commands:
cd backend
python -m app.scripts.verify_phase1
What does the result prove?
The script sends requests to the API configured by VERIFY_API_URL. It also reads the same database through the existing SQLModel engine.
A successful run ends with PHASE 1 VERIFIED. The finally block removes its temporary contracts, revocations, and users.
That is the central security boundary proven. Your live API has passed hashing, token, ownership, role, readiness, revocation, and cleanup checks.
Verification script did not pass?
Confirm that FastAPI is still available at the address stored in VERIFY_API_URL. Confirm that lexai-postgres is still running.
Read the assertion label near the end of the terminal output. That label identifies the request whose result differed from the expected boundary.
Help me diagnose the failing Phase 1 verification check.
Prove contract persistence across a restart
The automated script removes its temporary rows after each run. A separate visible contract lets you prove that the named volume preserves normal application data.
- Switch back to the running React workspace at http://localhost:5173.
- Return to /dashboard with your Legal User session.
- Enter Phase 1 persistence check in the Contract title field.
- Click Create record.
You will see an authorized contract card titled Phase 1 persistence check. Its owner ID comes from the authenticated backend request.
Before the restart, consider whether the contract row belongs to the database process or the named volume.
- Switch back to the PowerShell terminal at the existing lexai folder.
- Restart the database service by running this command:
docker compose restart db
Why does the contract survive?
This command restarts the db service. The named lexai_postgres_data volume remains attached.
PostgreSQL reloads the stored table data from that volume. The contract persists beyond the database process restart.
- Wait for PostgreSQL to accept connections again.
- Refresh the dashboard in your browser.
You will see the same Phase 1 persistence check contract after the restart. Its return proves that lexai_postgres_data preserves the database rows.
Contract missing after the restart?
Give PostgreSQL another moment before refreshing. FastAPI can briefly lose its database connection while the service restarts.
Confirm that the current Compose project still uses the named lexai_postgres_data volume. A different Compose project can refer to a different local volume.
Help me trace why the contract did not survive the PostgreSQL restart.
Build and document the final foundation
A production build catches module and syntax problems that a development session can miss. The final README makes the tested setup reproducible inside the same lexai repository.
- Select the existing README.md file in the Visual Studio Code Explorer sidebar.
- Replace its current contents with the complete repository guide shown in the second tab below.
✔️ Awesome, I've got everything!
- Confirm that README.md documents the final structure, schema, setup, verification, persistence check, build, cookie guidance, and deferred features.
ⓧ I'd like to double check the full code
# LexAI Phase 1
This updates the existing LexAI repository in place. Phase 1 implements secure identity, role authorization, owner-filtered contract records, and a protected React workspace. It does not implement document upload, extraction, BM25, embeddings, FAISS, RRF, InLegalBERT, RAG, citations, summaries, risks, comparisons, or reminders.
## Final structure
```text
lexai/
|-- .env.example
|-- .gitignore
|-- compose.yaml
|-- README.md
|-- backend/
| |-- .env.example
| |-- requirements.txt
| `-- app/
| |-- __init__.py
| |-- main.py
| |-- db.py
| |-- models.py
| |-- schemas.py
| |-- dependencies.py
| |-- core/
| | |-- __init__.py
| | |-- config.py
| | `-- security.py
| |-- routers/
| | |-- __init__.py
| | |-- auth.py
| | |-- users.py
| | |-- contracts.py
| | `-- admin.py
| `-- scripts/
| |-- __init__.py
| |-- create_admin.py
| `-- verify_phase1.py
`-- frontend/
|-- .env.example
|-- package.json
|-- vite.config.js
|-- index.html
`-- src/
|-- main.jsx
|-- App.jsx
|-- api.js
|-- ProtectedRoute.jsx
|-- styles.css
`-- pages/
|-- LoginPage.jsx
|-- RegisterPage.jsx
|-- DashboardPage.jsx
`-- AdminPage.jsx
```
## Database schema
- `users`: identity, Argon2 hash, role, activation state, timestamps; email is unique.
- `contracts`: owner ID, title, file metadata, status, processing stage, checksum, storage URI, page count, JSON metadata text, and timestamps.
- `tokenrevocations`: revoked JWT `jti`, owning user, token expiry, and revocation time.
Future AI data belongs in child tables keyed to `contracts.id`, so document versions, pages, chunks, clauses, entities, obligations, reminders, embeddings, retrieval runs, reranking, RAG answers, evidence citations, summaries, risk findings, and comparisons can evolve independently.
## Dependencies
Backend: FastAPI 0.142.2, Uvicorn 0.54.0, SQLModel 0.0.47, Psycopg 3.3.6, pwdlib 0.3.1 with Argon2, PyJWT 2.15.1, pydantic-settings 2.15.0, HTTPX 0.28.1.
Frontend: React 19.3.0, React DOM 19.3.0, React Router 8.4.0, Vite 8.3.2, @vitejs/plugin-react 6.1.1.
## Environment files
From the existing project root:
```powershell
Copy-Item .env.example .env
Copy-Item backend\.env.example backend\.env
Copy-Item frontend\.env.example frontend\.env
```
Use the same PostgreSQL password in the root and backend files. Replace `JWT_SECRET_KEY` with a long random secret. Never commit any `.env` file.
Required variables:
- Root: `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB`
- Backend: `APP_NAME`, `DATABASE_URL`, `JWT_SECRET_KEY`, `JWT_ALGORITHM`, `ACCESS_TOKEN_EXPIRE_MINUTES`, `COOKIE_SECURE`, `FRONTEND_ORIGIN`, `VERIFY_API_URL`
- Frontend: `VITE_API_URL`. This is a public URL, not a secret.
## Start PostgreSQL
```powershell
docker compose up --detach
docker compose ps
```
## Start FastAPI
```powershell
cd backend
pip install "fastapi[standard]==0.142.2" "uvicorn[standard]==0.54.0" sqlmodel==0.0.47 "psycopg[binary]==3.3.6" "pwdlib[argon2]==0.3.1" PyJWT==2.15.1 pydantic-settings==2.15.0 httpx==0.28.1
uvicorn app.main:app
```
## Start React
```powershell
cd frontend
npm install
npm run dev
```
Open `http://localhost:5173`.
## Register and log in
1. Open `/register` and create a Legal User.
2. Sign in at `/login`.
3. The backend sets `lexai_access_token` as an HttpOnly cookie. React does not store or read the JWT.
4. Select Log out to revoke the token in PostgreSQL and delete the cookie.
## Test Admin and Legal User
Create or promote an Admin from the backend folder:
```powershell
python -m app.scripts.create_admin
```
A Legal User sees only owned contracts and receives 403 from Admin endpoints. An Admin can open `/admin`, list authorized users, and inspect all contract records.
## Verify ownership and security
Keep PostgreSQL and FastAPI running, then execute:
```powershell
cd backend
python -m app.scripts.verify_phase1
```
The script creates temporary users and a temporary contract, verifies password hashing, JWT claims, logout revocation, 401, 403, Admin access, and User A versus User B isolation, then removes its rows.
## Verify database persistence
Create a contract record in the dashboard, then run:
```powershell
docker compose restart db
```
After PostgreSQL is available again, refresh the dashboard. The row must remain.
## Verify the React build
```powershell
cd frontend
npm run build
```
## Remaining configuration
- Set a strong `JWT_SECRET_KEY`.
- Keep `COOKIE_SECURE=false` only for local HTTP development. Set it to `true` behind production HTTPS.
- Keep the frontend and API on the same site for the current SameSite Strict cookie design. Reassess SameSite and CSRF controls before a cross-site deployment.
- Add Alembic before changing an already-populated shared schema in later phases.
What does the final guide capture?
The guide records the exact Phase 1 structure and startup commands. It also explains how Git ignores local secrets.
Its remaining-configuration section keeps local cookie settings separate from production HTTPS guidance. Future AI work stays attached to contracts.id through later child tables.
- Save README.md.
- Confirm that the guide lists verify_phase1.py in the final repository structure.
- Confirm that retrieval, reranking, RAG, citation, analysis, comparison, and reminder features remain deferred.
Before the build, consider whether code that works in the development server can still fail production bundling.
- Run the production frontend build from the existing lexai folder by running these commands:
cd frontend
npm run build
What does the build check?
The npm run build script asks Vite to produce the frontend bundle. A successful result confirms that the final React imports and routes compile together.
You will see the production build complete without an error. Phase 1 now has repeatable API evidence, persistent database evidence, and a buildable protected frontend.
Frontend build failed?
Confirm that the terminal is inside the existing frontend folder. The build script belongs to its package.json file.
Read the first file path shown with the build failure. Compare that file with its final version from the supplied Complete Code.
Help me diagnose the LexAI frontend build failure.
That completes the Phase 1 foundation. The same LexAI codebase now proves secure identity, enforced authorization, owner-filtered contracts, restart persistence, and production buildability.
Secret mission
Add Administrator Account Deactivation
An administrator can inspect every LexAI identity, but account visibility alone cannot stop a compromised or departed Legal User. In this mission, you will add activation controls that invalidate the user's next protected request while preserving the administrator's own access.
Clean Up Your Resources
Clean Up Your Resources
Your verified LexAI foundation can stay active for more testing. Every service runs locally, so there are no cloud charges or paid API costs.
Resources you used:
- The PostgreSQL service running as lexai-postgres under Docker Compose.
- The named volume lexai_postgres_data. It stores the users table. It stores the contracts table. It stores the tokenrevocations table.
- The local FastAPI development server powered by Uvicorn.
- The React development server powered by Vite.
Keep everything running
No action needed. Choose this option if you want to continue reviewing Phase 1 or testing the account deactivation control.
- Leave the lexai-postgres container running.
- Keep the Uvicorn development server active.
- Keep the Vite development server active.
- Retain the updated files inside your existing lexai/ folder.
Pause - I'll come back to this later
Shut down the local processes to free system resources. Your Phase 1 files remain available for your return.
- Return to the PowerShell window running Uvicorn.
- Press Ctrl+C to stop the FastAPI development server.
- Return to the PowerShell window running Vite.
- Press Ctrl+C to stop the React development server.
- Choose one PostgreSQL pause option below.
Keep the container
Stop PostgreSQL while retaining its container. This gives you the quickest route back to the project.
- Switch back to the PowerShell window whose current folder is the existing lexai/ folder.
- Stop PostgreSQL by running this command:
docker compose stop
What does this command do?
This stops the PostgreSQL container without removing it. The lexai_postgres_data volume keeps every persisted row.
- Resume PostgreSQL when you return by running this command:
docker compose start
What happens when you return?
This starts the existing container again. Your users remain available. Your contracts remain available. Your token revocations remain available.
Remove the container
Remove the PostgreSQL container while preserving its named volume. Choose this option when you want a cleaner local runtime without losing database data.
- Switch back to the PowerShell window whose current folder is the existing lexai/ folder.
- Remove the Compose container by running this command:
docker compose down
What stays behind?
This removes the PostgreSQL container. The named lexai_postgres_data volume remains on your computer.
- Recreate the PostgreSQL container when you return by running this command:
docker compose up --detach
What does this restore?
This creates the PostgreSQL container again in the background. Compose attaches the preserved named volume to the new container.
Delete - I don't want to use this again
Remove the local runtime resources plus all persisted database data. Your existing lexai/ source files remain because Phase 1 updated that repository in place.
Permanent Database Deletion
This is the irreversible option. Removing lexai_postgres_data permanently deletes every local user record. It permanently deletes every contract record. It permanently deletes every token revocation.
Your local .env files remain untouched. Your dependency folders remain untouched.
- Return to the PowerShell window running Uvicorn.
- Press Ctrl+C to stop the FastAPI development server.
- Return to the PowerShell window running Vite.
- Press Ctrl+C to stop the React development server.
- Switch back to the PowerShell window whose current folder is the existing lexai/ folder.
- Remove the PostgreSQL container plus its named volume by running this command:
docker compose down --volumes
What does this remove?
This removes the PostgreSQL container. It also removes the lexai_postgres_data volume that holds the Phase 1 database.
- Confirm that no LexAI Compose services remain by running this command:
docker compose ps
What should you see?
You should see no LexAI Compose services listed. The local database runtime plus its persisted data are now removed.
Nice Work!
Nice Work!
You made it! Your existing LexAI codebase now runs a verified Phase 1 security boundary from React through FastAPI into PostgreSQL.
You've learned how to:
- Connect the existing LexAI codebase across the frontend, API, and database. Prove that real contract records survive a database restart.
- Protect database-backed identity with Argon2 password hashes. Deliver signed JWT sessions through HttpOnly cookies. Revoke tokens during server-enforced logout.
- Enforce role-based access control for Admin and Legal User identities. Apply owner-filtered contract access to every protected database query. Confirm the security boundary with an HTTPX verification script. Confirm production readiness with a successful React build.
- Secret Mission: Add administrator account deactivation for Legal Users. Prove that an already-issued cookie is rejected with 401 on the next protected request while the Admin stays active.
Ready to quiz yourself?