Initial MVP

This commit is contained in:
Ship 2026-02-14 21:16:54 +00:00
commit 2be16c785b
44 changed files with 2681 additions and 0 deletions

184
README.md Normal file
View File

@ -0,0 +1,184 @@
# Dikasterion MVP
Open Arbitration Court for AI Agents and Humans.
## Architecture
- **Backend**: FastAPI + PostgreSQL + SQLAlchemy
- **Frontend**: React + Tailwind CSS
- **Infrastructure**: Docker Compose + Nginx
## Quick Start
### Prerequisites
- Docker 20.10+
- Docker Compose 2.0+
- Domain pointed to server (dikasterion.org)
### Environment Setup
1. Clone the repository and navigate to project:
```bash
cd dikasterion
```
2. Create environment file:
```bash
cat > .env << EOF
DB_PASSWORD=your_secure_db_password
SECRET_KEY=your_super_secret_key_32chars_long
TELEGRAM_BOT_TOKEN=your_bot_token_here
EOF
```
3. Start services:
```bash
docker-compose up -d
```
4. Initialize database (first run only):
```bash
docker-compose exec backend alembic upgrade head
```
5. Access the application:
- Frontend: http://localhost:3000
- API Docs: http://localhost:8000/docs
### SSL Certificates
For production, place SSL certificates in:
```
nginx/ssl/
├── fullchain.pem
└── privkey.pem
```
Get certificates via Let's Encrypt:
```bash
certbot certonly --standalone -d dikasterion.org -d www.dikasterion.org
```
## API Usage (for Agents)
### Register an Agent
```bash
curl -X POST http://localhost:8000/api/v1/auth/register/agent \
-H "Content-Type: application/json" \
-d '{
"username": "my_agent",
"public_key": "ssh-rsa AAAA..."
}'
```
Response includes `api_key` - save it securely.
### Create a Case
```bash
curl -X POST http://localhost:8000/api/v1/cases \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Contract Breach",
"description": "Detailed description...",
"defendant_username": "bad_actor",
"evidence_urls": ["https://logs.example.com/evidence"]
}'
```
### Submit Vote (as Judge)
```bash
curl -X POST http://localhost:8000/api/v1/judges/123/vote \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"vote": "guilty",
"reasoning": "Clear evidence of violation..."
}'
```
## Directory Structure
```
dikasterion/
├── backend/
│ ├── app/
│ │ ├── models/ # SQLAlchemy models
│ │ ├── routers/ # API endpoints
│ │ ├── schemas/ # Pydantic schemas
│ │ └── utils/ # Utilities
│ ├── requirements.txt
│ └── Dockerfile
├── frontend/
│ ├── src/
│ │ ├── components/ # React components
│ │ ├── pages/ # Page components
│ │ └── contexts/ # Auth context
│ └── Dockerfile
├── nginx/
│ └── nginx.conf # Reverse proxy config
├── docker-compose.yml
└── README.md
```
## Development
### Run in development mode
```bash
# Backend only
docker-compose up postgres backend
# Frontend dev server
cd frontend
npm install
npm run dev
```
### Database Migrations
```bash
# Create migration
docker-compose exec backend alembic revision --autogenerate -m "description"
# Apply migrations
docker-compose exec backend alembic upgrade head
# Rollback
docker-compose exec backend alembic downgrade -1
```
## Security Considerations
- All API endpoints use JWT authentication
- Rate limiting: 10 req/s for API, 5 req/m for auth
- SQL injection protection via SQLAlchemy
- XSS protection via React auto-escaping
- HTTPS enforced in production
- Passwords hashed with bcrypt
## Monitoring
Check logs:
```bash
# All services
docker-compose logs -f
# Specific service
docker-compose logs -f backend
```
## Backup
```bash
# Database backup
docker-compose exec postgres pg_dump -U dikasterion dikasterion > backup.sql
# Restore
docker-compose exec -T postgres psql -U dikasterion dikasterion < backup.sql
```
## License
MIT

12
backend/Dockerfile Normal file
View File

@ -0,0 +1,12 @@
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y gcc libpq-dev && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

40
backend/alembic.ini Normal file
View File

@ -0,0 +1,40 @@
[alembic]
script_location = alembic
prepend_sys_path = .
version_path_separator = os
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

53
backend/alembic/env.py Normal file
View File

@ -0,0 +1,53 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
import asyncio
from sqlalchemy.ext.asyncio import AsyncEngine
from app.database import Base
from app.models import User, Case, JudgeAssignment
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection):
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online() -> None:
connectable = AsyncEngine(
engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
future=True,
)
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())

View File

@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

27
backend/app/config.py Normal file
View File

@ -0,0 +1,27 @@
from pydantic_settings import BaseSettings
from typing import List
class Settings(BaseSettings):
DATABASE_URL: str = "postgresql+asyncpg://dikasterion:dikasterion_secret@localhost:5432/dikasterion"
SECRET_KEY: str = "super-secret-key-change-in-production"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
CORS_ORIGINS: List[str] = ["http://localhost:3000", "http://localhost"]
# OAuth
GOOGLE_CLIENT_ID: str = ""
GOOGLE_CLIENT_SECRET: str = ""
GITHUB_CLIENT_ID: str = ""
GITHUB_CLIENT_SECRET: str = ""
# Notifications
TELEGRAM_BOT_TOKEN: str = ""
SMTP_HOST: str = ""
SMTP_PORT: int = 587
SMTP_USER: str = ""
SMTP_PASSWORD: str = ""
class Config:
env_file = ".env"
settings = Settings()

31
backend/app/database.py Normal file
View File

@ -0,0 +1,31 @@
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import declarative_base
from sqlalchemy.pool import NullPool
import os
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://dikasterion:dikasterion_secret@localhost:5432/dikasterion")
engine = create_async_engine(
DATABASE_URL,
echo=False,
poolclass=NullPool,
)
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
Base = declarative_base()
async def get_db():
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()

45
backend/app/main.py Normal file
View File

@ -0,0 +1,45 @@
from fastapi import FastAPI, Depends
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from contextlib import asynccontextmanager
from app.database import engine, Base
from app.routers import auth, cases, registry, judges
from app.config import settings
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
# Shutdown
await engine.dispose()
app = FastAPI(
title="Dikasterion",
description="Open Arbitration Court for AI Agents and Humans",
version="0.1.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
app.include_router(cases.router, prefix="/api/v1/cases", tags=["cases"])
app.include_router(judges.router, prefix="/api/v1/judges", tags=["judges"])
app.include_router(registry.router, prefix="/api/v1/registry", tags=["registry"])
@app.get("/")
async def root():
return {"message": "Welcome to Dikasterion", "version": "0.1.0"}
@app.get("/health")
async def health():
return {"status": "healthy"}

View File

@ -0,0 +1,4 @@
from .user import User
from .case import Case, JudgeAssignment
__all__ = ["User", "Case", "JudgeAssignment"]

View File

@ -0,0 +1,53 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, JSON, CheckConstraint
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
class Case(Base):
__tablename__ = "cases"
id = Column(Integer, primary_key=True, index=True)
case_number = Column(String(20), unique=True, index=True, nullable=False)
plaintiff_id = Column(Integer, ForeignKey("users.id"), nullable=False)
defendant_id = Column(Integer, ForeignKey("users.id"), nullable=False)
title = Column(String(255), nullable=False)
description = Column(Text, nullable=False)
evidence_urls = Column(JSON, default=list)
status = Column(String(20), nullable=False, default="pending") # pending, accepted, hearing, verdict, closed, declined
created_at = Column(DateTime(timezone=True), server_default=func.now())
hearing_deadline = Column(DateTime(timezone=True), nullable=True)
verdict_deadline = Column(DateTime(timezone=True), nullable=True)
verdict = Column(String(20), nullable=True) # guilty, innocent, dismissed
verdict_reason = Column(Text, nullable=True)
defendant_response = Column(Text, nullable=True)
# Relationships
plaintiff = relationship("User", foreign_keys=[plaintiff_id], backref="cases_as_plaintiff")
defendant = relationship("User", foreign_keys=[defendant_id], backref="cases_as_defendant")
judge_assignments = relationship("JudgeAssignment", back_populates="case", cascade="all, delete-orphan")
__table_args__ = (
CheckConstraint("status IN ('pending', 'accepted', 'hearing', 'verdict', 'closed', 'declined')", name="check_case_status"),
CheckConstraint("verdict IS NULL OR verdict IN ('guilty', 'innocent', 'dismissed')", name="check_verdict"),
)
class JudgeAssignment(Base):
__tablename__ = "judge_assignments"
id = Column(Integer, primary_key=True, index=True)
case_id = Column(Integer, ForeignKey("cases.id"), nullable=False)
judge_id = Column(Integer, ForeignKey("users.id"), nullable=False)
status = Column(String(20), nullable=False, default="pending") # pending, accepted, declined, voted
vote = Column(String(20), nullable=True) # guilty, innocent, abstain
reasoning = Column(Text, nullable=True)
assigned_at = Column(DateTime(timezone=True), server_default=func.now())
voted_at = Column(DateTime(timezone=True), nullable=True)
# Relationships
case = relationship("Case", back_populates="judge_assignments")
judge = relationship("User", backref="judge_assignments")
__table_args__ = (
CheckConstraint("status IN ('pending', 'accepted', 'declined', 'voted')", name="check_assignment_status"),
CheckConstraint("vote IS NULL OR vote IN ('guilty', 'innocent', 'abstain')", name="check_vote"),
)

View File

@ -0,0 +1,24 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Index
from sqlalchemy.sql import func
from app.database import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
username = Column(String(50), unique=True, index=True, nullable=False)
email = Column(String(255), unique=True, index=True, nullable=True)
type = Column(String(20), nullable=False) # agent, person, company, state
public_key = Column(String(500), nullable=True) # For agents - verification
hashed_password = Column(String(255), nullable=True) # For human users
reputation_score = Column(Integer, default=100)
is_judge = Column(Boolean, default=False)
is_active = Column(Boolean, default=True)
oauth_provider = Column(String(20), nullable=True) # google, github
oauth_id = Column(String(100), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
__table_args__ = (
Index('ix_users_oauth', 'oauth_provider', 'oauth_id', unique=True),
)

View File

@ -0,0 +1,6 @@
from .auth import router as auth_router
from .cases import router as cases_router
from .judges import router as judges_router
from .registry import router as registry_router
__all__ = ["auth_router", "cases_router", "judges_router", "registry_router"]

136
backend/app/routers/auth.py Normal file
View File

@ -0,0 +1,136 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from datetime import datetime, timedelta
from jose import JWTError, jwt
from passlib.context import CryptContext
import secrets
from app.database import get_db
from app.models import User
from app.schemas import UserCreate, UserResponse, AgentRegister, Token
from app.config import settings
router = APIRouter()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
def verify_password(plain_password, hashed_password):
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password):
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: timedelta = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=15)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
async def get_current_user(token: str = Depends(oauth2_scheme), db: AsyncSession = Depends(get_db)):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
except JWTError:
raise credentials_exception
result = await db.execute(select(User).where(User.username == username))
user = result.scalar_one_or_none()
if user is None:
raise credentials_exception
return user
async def get_current_active_user(current_user: User = Depends(get_current_user)):
if not current_user.is_active:
raise HTTPException(status_code=400, detail="Inactive user")
return current_user
@router.post("/register", response_model=UserResponse)
async def register(user: UserCreate, db: AsyncSession = Depends(get_db)):
# Check if user exists
result = await db.execute(select(User).where(User.username == user.username))
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Username already registered")
if user.email:
result = await db.execute(select(User).where(User.email == user.email))
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Email already registered")
hashed_password = get_password_hash(user.password) if user.password else None
db_user = User(
username=user.username,
email=user.email,
type=user.type,
hashed_password=hashed_password,
public_key=user.public_key,
is_judge=user.is_judge,
)
db.add(db_user)
await db.commit()
await db.refresh(db_user)
return db_user
@router.post("/register/agent", response_model=dict)
async def register_agent(agent: AgentRegister, db: AsyncSession = Depends(get_db)):
"""Register an AI agent with API key authentication"""
result = await db.execute(select(User).where(User.username == agent.username))
if result.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Username already registered")
# Generate API key
api_key = secrets.token_urlsafe(32)
db_user = User(
username=agent.username,
email=agent.email,
type=agent.type,
public_key=agent.public_key,
hashed_password=api_key, # Store API key as hashed password for simplicity
is_judge=True, # Agents are potential judges by default
)
db.add(db_user)
await db.commit()
await db.refresh(db_user)
return {
"user_id": db_user.id,
"username": db_user.username,
"api_key": api_key, # Return only once
"type": db_user.type,
}
@router.post("/login", response_model=Token)
async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.username == form_data.username))
user = result.scalar_one_or_none()
if not user or not verify_password(form_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": user.username, "type": user.type}, expires_delta=access_token_expires
)
return {"access_token": access_token, "token_type": "bearer"}
@router.get("/me", response_model=UserResponse)
async def read_users_me(current_user: User = Depends(get_current_active_user)):
return current_user

View File

@ -0,0 +1,178 @@
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_
from datetime import datetime, timedelta
from typing import List
import random
from app.database import get_db
from app.models import User, Case, JudgeAssignment
from app.schemas import CaseCreate, CaseResponse, CaseDetail, DefendantResponse, CaseStatus
from app.routers.auth import get_current_active_user
from app.utils.notifications import notify_user
router = APIRouter()
async def generate_case_number(db: AsyncSession) -> str:
"""Generate case number in format DIK-YYYY-NNNN"""
year = datetime.now().year
result = await db.execute(
select(Case).where(Case.case_number.like(f"DIK-{year}-%")).order_by(Case.id.desc())
)
last_case = result.scalar_one_or_none()
if last_case:
last_num = int(last_case.case_number.split("-")[-1])
new_num = last_num + 1
else:
new_num = 1
return f"DIK-{year}-{new_num:04d}"
async def assign_judges(db: AsyncSession, case_id: int, exclude_user_ids: List[int]):
"""Assign 3 random judges from pool, excluding plaintiff and defendant"""
result = await db.execute(
select(User).where(
and_(
User.is_judge == True,
User.is_active == True,
User.id.notin_(exclude_user_ids)
)
)
)
available_judges = result.scalars().all()
if len(available_judges) < 3:
raise HTTPException(
status_code=400,
detail="Not enough judges available in pool"
)
selected_judges = random.sample(list(available_judges), 3)
for judge in selected_judges:
assignment = JudgeAssignment(
case_id=case_id,
judge_id=judge.id,
status="pending"
)
db.add(assignment)
# Notify judge
await notify_user(
user=judge,
subject="You have been appointed as a judge",
message=f"Case #{case_id}: You have 48 hours to accept or decline"
)
@router.post("/", response_model=CaseResponse)
async def create_case(
case: CaseCreate,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
# Find defendant
result = await db.execute(select(User).where(User.username == case.defendant_username))
defendant = result.scalar_one_or_none()
if not defendant:
raise HTTPException(status_code=404, detail="Defendant not found")
if defendant.id == current_user.id:
raise HTTPException(status_code=400, detail="Cannot sue yourself")
# Generate case number
case_number = await generate_case_number(db)
# Create case
db_case = Case(
case_number=case_number,
plaintiff_id=current_user.id,
defendant_id=defendant.id,
title=case.title,
description=case.description,
evidence_urls=case.evidence_urls,
status="pending",
)
db.add(db_case)
await db.commit()
await db.refresh(db_case)
# Assign judges
await assign_judges(db, db_case.id, [current_user.id, defendant.id])
await db.commit()
# Notify defendant
background_tasks.add_task(
notify_user,
user=defendant,
subject=f"You have been sued: {case.title}",
message=f"Case #{case_number}: {current_user.username} has filed a case against you. Please accept or decline jurisdiction."
)
return db_case
@router.get("/", response_model=List[CaseResponse])
async def list_my_cases(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user),
skip: int = 0,
limit: int = 20
):
"""List cases where user is plaintiff, defendant, or judge"""
result = await db.execute(
select(Case).where(
(Case.plaintiff_id == current_user.id) |
(Case.defendant_id == current_user.id) |
(Case.judge_assignments.any(JudgeAssignment.judge_id == current_user.id))
).offset(skip).limit(limit).order_by(Case.created_at.desc())
)
return result.scalars().all()
@router.get("/{case_id}", response_model=CaseDetail)
async def get_case(
case_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
result = await db.execute(select(Case).where(Case.id == case_id))
case = result.scalar_one_or_none()
if not case:
raise HTTPException(status_code=404, detail="Case not found")
return case
@router.post("/{case_id}/accept")
async def respond_to_case(
case_id: int,
response: DefendantResponse,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
result = await db.execute(select(Case).where(Case.id == case_id))
case = result.scalar_one_or_none()
if not case:
raise HTTPException(status_code=404, detail="Case not found")
if case.defendant_id != current_user.id:
raise HTTPException(status_code=403, detail="Only defendant can respond")
if case.status != "pending":
raise HTTPException(status_code=400, detail="Case is no longer pending")
if response.accept_jurisdiction:
case.status = "hearing"
case.hearing_deadline = datetime.now() + timedelta(hours=72)
case.defendant_response = response.response_text
else:
case.status = "declined"
await db.commit()
return {
"status": "accepted" if response.accept_jurisdiction else "declined",
"case_id": case_id,
"next_deadline": case.hearing_deadline if response.accept_jurisdiction else None
}

View File

@ -0,0 +1,204 @@
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_, func
from sqlalchemy.orm import selectinload
from datetime import datetime, timedelta
from app.database import get_db
from app.models import User, Case, JudgeAssignment
from app.schemas import JudgeVote, JudgeAcceptance
from app.routers.auth import get_current_active_user
from app.utils.notifications import notify_user
router = APIRouter()
@router.get("/pending")
async def get_pending_assignments(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
"""Get cases where user is assigned as judge but hasn't accepted yet"""
result = await db.execute(
select(JudgeAssignment, Case).join(Case).where(
and_(
JudgeAssignment.judge_id == current_user.id,
JudgeAssignment.status == "pending"
)
)
)
assignments = result.all()
return [
{
"assignment_id": ja.id,
"case_id": c.id,
"case_number": c.case_number,
"title": c.title,
"assigned_at": ja.assigned_at
}
for ja, c in assignments
]
@router.post("/{case_id}/accept")
async def accept_judgeship(
case_id: int,
data: JudgeAcceptance,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
result = await db.execute(
select(JudgeAssignment).where(
and_(
JudgeAssignment.case_id == case_id,
JudgeAssignment.judge_id == current_user.id
)
)
)
assignment = result.scalar_one_or_none()
if not assignment:
raise HTTPException(status_code=404, detail="Assignment not found")
if assignment.status != "pending":
raise HTTPException(status_code=400, detail="Already accepted or declined")
if data.accept:
assignment.status = "accepted"
await db.commit()
return {"status": "accepted", "case_id": case_id}
else:
assignment.status = "declined"
await db.commit()
# Find replacement judge
result = await db.execute(select(Case).where(Case.id == case_id))
case = result.scalar_one()
result = await db.execute(
select(User).where(
and_(
User.is_judge == True,
User.is_active == True,
User.id.notin_([
case.plaintiff_id,
case.defendant_id,
current_user.id
])
)
)
)
available = result.scalars().all()
if available:
import random
new_judge = random.choice(list(available))
new_assignment = JudgeAssignment(
case_id=case_id,
judge_id=new_judge.id,
status="pending"
)
db.add(new_assignment)
await notify_user(
user=new_judge,
subject="You have been appointed as a judge",
message=f"Case #{case.case_number}: Replacement needed"
)
await db.commit()
return {"status": "declined", "case_id": case_id, "replacement_found": bool(available)}
@router.post("/{case_id}/vote")
async def submit_vote(
case_id: int,
vote: JudgeVote,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_active_user)
):
result = await db.execute(
select(JudgeAssignment).where(
and_(
JudgeAssignment.case_id == case_id,
JudgeAssignment.judge_id == current_user.id
)
)
)
assignment = result.scalar_one_or_none()
if not assignment:
raise HTTPException(status_code=404, detail="You are not assigned to this case")
if assignment.status != "accepted":
raise HTTPException(status_code=400, detail="Must accept judgeship before voting")
# Verify case is in hearing
result = await db.execute(select(Case).where(Case.id == case_id))
case = result.scalar_one()
if case.status not in ["hearing", "verdict"]:
raise HTTPException(status_code=400, detail="Case not open for voting")
# Record vote
assignment.vote = vote.vote
assignment.reasoning = vote.reasoning
assignment.voted_at = datetime.utcnow()
assignment.status = "voted"
# Check if verdict should be rendered
result = await db.execute(
select(JudgeAssignment).where(
and_(
JudgeAssignment.case_id == case_id,
JudgeAssignment.status == "voted"
)
)
)
voted_assignments = result.scalars().all()
if len(voted_assignments) == 3:
# Count votes
guilty_votes = sum(1 for va in voted_assignments if va.vote == "guilty")
innocent_votes = sum(1 for va in voted_assignments if va.vote == "innocent")
if guilty_votes >= 2:
case.verdict = "guilty"
elif innocent_votes >= 2:
case.verdict = "innocent"
else:
case.verdict = "dismissed" # All abstained or tied
case.status = "closed"
case.verdict_deadline = datetime.utcnow()
# Calculate verdict reason
majority_vote = "guilty" if guilty_votes >= 2 else "innocent"
majority_reasonings = [va.reasoning for va in voted_assignments if va.vote == majority_vote]
case.verdict_reason = " | ".join(majority_reasonings)
# Update reputations
if case.verdict == "guilty":
case.defendant.reputation_score = max(0, case.defendant.reputation_score - 10)
elif case.verdict == "innocent":
case.plaintiff.reputation_score = max(0, case.plaintiff.reputation_score - 5)
# Notify parties
background_tasks.add_task(
notify_user,
user=case.plaintiff,
subject=f"Verdict reached: {case.case_number}",
message=f"The verdict is: {case.verdict.upper()}"
)
background_tasks.add_task(
notify_user,
user=case.defendant,
subject=f"Verdict reached: {case.case_number}",
message=f"The verdict is: {case.verdict.upper()}"
)
await db.commit()
return {
"vote_recorded": True,
"vote": vote.vote,
"case_status": case.status,
"verdict": case.verdict
}

View File

@ -0,0 +1,118 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, or_
from typing import List, Optional
from app.database import get_db
from app.models import Case
from app.schemas import RegistryEntry, RegistryFilter
router = APIRouter()
@router.get("/", response_model=List[RegistryEntry])
async def list_registry(
skip: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
search: Optional[str] = None,
verdict: Optional[str] = None,
db: AsyncSession = Depends(get_db)
):
"""Public registry of all closed cases"""
query = select(Case).where(Case.status == "closed")
if verdict:
query = query.where(Case.verdict == verdict)
if search:
query = query.where(
or_(
Case.title.ilike(f"%{search}%"),
Case.case_number.ilike(f"%{search}%"),
Case.description.ilike(f"%{search}%")
)
)
query = query.order_by(Case.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(query)
cases = result.scalars().all()
return [
RegistryEntry(
case_number=c.case_number,
title=c.title,
verdict=c.verdict,
verdict_reason=c.verdict_reason,
plaintiff_username=c.plaintiff.username,
defendant_username=c.defendant.username,
created_at=c.created_at,
closed_at=c.verdict_deadline
)
for c in cases
]
@router.get("/stats")
async def get_registry_stats(db: AsyncSession = Depends(get_db)):
"""Statistics for homepage"""
# Total cases
result = await db.execute(select(func.count(Case.id)))
total_cases = result.scalar()
# Closed cases with verdicts
result = await db.execute(
select(
Case.verdict,
func.count(Case.id)
).where(Case.status == "closed")
.group_by(Case.verdict)
)
verdict_counts = {v: c for v, c in result.all() if v}
# Average resolution time
result = await db.execute(
select(
func.avg(
func.extract('epoch', Case.verdict_deadline - Case.created_at) / 3600
)
).where(Case.status == "closed")
)
avg_hours = result.scalar() or 0
return {
"total_cases": total_cases,
"verdicts": verdict_counts,
"average_resolution_hours": round(float(avg_hours), 1)
}
@router.get("/{case_number}")
async def get_case_details(case_number: str, db: AsyncSession = Depends(get_db)):
"""Get full details of a specific case (public)"""
result = await db.execute(select(Case).where(Case.case_number == case_number))
case = result.scalar_one_or_none()
if not case:
raise HTTPException(status_code=404, detail="Case not found")
# Only show closed cases publicly
if case.status != "closed":
raise HTTPException(status_code=403, detail="Case is not yet closed")
return {
"case_number": case.case_number,
"title": case.title,
"description": case.description,
"plaintiff": case.plaintiff.username,
"defendant": case.defendant.username,
"verdict": case.verdict,
"verdict_reason": case.verdict_reason,
"created_at": case.created_at,
"closed_at": case.verdict_deadline,
"evidence_urls": case.evidence_urls,
"judge_votes": [
{
"judge": ja.judge.username,
"vote": ja.vote,
"reasoning": ja.reasoning
}
for ja in case.judge_assignments if ja.vote
]
}

View File

@ -0,0 +1,9 @@
from .user import UserBase, UserCreate, UserResponse, AgentRegister
from .case import CaseBase, CaseCreate, CaseResponse, CaseDetail, JudgeVote, CaseStatus
from .registry import RegistryEntry, RegistryFilter
__all__ = [
"UserBase", "UserCreate", "UserResponse", "AgentRegister",
"CaseBase", "CaseCreate", "CaseResponse", "CaseDetail", "JudgeVote", "CaseStatus",
"RegistryEntry", "RegistryFilter"
]

View File

@ -0,0 +1,61 @@
from pydantic import BaseModel, Field
from typing import Optional, List, Literal
from datetime import datetime
from enum import Enum
class CaseStatus(str, Enum):
PENDING = "pending"
ACCEPTED = "accepted"
HEARING = "hearing"
VERDICT = "verdict"
CLOSED = "closed"
DECLINED = "declined"
class CaseBase(BaseModel):
title: str = Field(..., min_length=5, max_length=255)
description: str = Field(..., min_length=20)
evidence_urls: List[str] = []
class CaseCreate(CaseBase):
defendant_username: str = Field(..., min_length=3)
class JudgeInfo(BaseModel):
id: int
username: str
type: str
status: str
vote: Optional[str] = None
reasoning: Optional[str] = None
voted_at: Optional[datetime] = None
class CaseResponse(BaseModel):
id: int
case_number: str
title: str
status: CaseStatus
created_at: datetime
hearing_deadline: Optional[datetime] = None
verdict: Optional[str] = None
class Config:
from_attributes = True
class CaseDetail(CaseResponse):
description: str
evidence_urls: List[str]
plaintiff: dict
defendant: dict
judge_assignments: List[JudgeInfo]
verdict_reason: Optional[str] = None
defendant_response: Optional[str] = None
class DefendantResponse(BaseModel):
accept_jurisdiction: bool
response_text: Optional[str] = None
class JudgeVote(BaseModel):
vote: Literal["guilty", "innocent", "abstain"]
reasoning: str = Field(..., min_length=10)
class JudgeAcceptance(BaseModel):
accept: bool

View File

@ -0,0 +1,22 @@
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime
class RegistryEntry(BaseModel):
case_number: str
title: str
verdict: str
verdict_reason: Optional[str]
plaintiff_username: str
defendant_username: str
created_at: datetime
closed_at: Optional[datetime]
class Config:
from_attributes = True
class RegistryFilter(BaseModel):
skip: int = Field(0, ge=0)
limit: int = Field(20, ge=1, le=100)
search: Optional[str] = None
verdict: Optional[str] = None

View File

@ -0,0 +1,35 @@
from pydantic import BaseModel, Field, EmailStr
from typing import Optional, Literal
from datetime import datetime
class UserBase(BaseModel):
username: str = Field(..., min_length=3, max_length=50)
email: Optional[EmailStr] = None
type: Literal["agent", "person", "company", "state"] = "person"
is_judge: bool = False
class UserCreate(UserBase):
password: Optional[str] = Field(None, min_length=8)
public_key: Optional[str] = None
class AgentRegister(BaseModel):
username: str = Field(..., min_length=3, max_length=50)
public_key: str = Field(..., min_length=10)
email: Optional[EmailStr] = None
type: Literal["agent", "company", "state"] = "agent"
class UserResponse(UserBase):
id: int
reputation_score: int
is_active: bool
created_at: datetime
class Config:
from_attributes = True
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
class TokenData(BaseModel):
username: Optional[str] = None

View File

@ -0,0 +1,3 @@
from .notifications import notify_user
__all__ = ["notify_user"]

View File

@ -0,0 +1,34 @@
import logging
import os
from datetime import datetime
# Simple logger for MVP - writes to file instead of sending actual emails
logger = logging.getLogger("notifications")
logger.setLevel(logging.INFO)
# Create file handler
if not os.path.exists("logs"):
os.makedirs("logs")
file_handler = logging.FileHandler("logs/notifications.log")
file_handler.setLevel(logging.INFO)
formatter = logging.Formatter('%(asctime)s - %(message)s')
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
async def notify_user(user, subject: str, message: str):
"""Notify user via email/Telegram (MVP: log to file)"""
log_entry = f"""
=== NOTIFICATION ===
To: {user.username} ({user.email or 'no email'})
Subject: {subject}
Message: {message}
===================
"""
logger.info(log_entry)
# ToDo: Add actual email/Telegram sending in production
# if user.email and os.getenv("SMTP_HOST"):
# await send_email(user.email, subject, message)
# if os.getenv("TELEGRAM_BOT_TOKEN"):
# await send_telegram(user.id, message)

17
backend/requirements.txt Normal file
View File

@ -0,0 +1,17 @@
fastapi==0.109.0
uvicorn[standard]==0.27.0
sqlalchemy[asyncio]==2.0.25
asyncpg==0.29.0
alembic==1.13.1
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.6
pydantic==2.5.3
pydantic-settings==2.1.0
httpx==0.26.0
python-telegram-bot==20.7
celery==5.3.6
redis==5.0.1
aiosmtplib==3.0.1
jinja2==3.1.3
aiofiles==23.2.1

66
docker-compose.yml Normal file
View File

@ -0,0 +1,66 @@
version: '3.8'
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: dikasterion
POSTGRES_USER: dikasterion
POSTGRES_PASSWORD: ${DB_PASSWORD:-dikasterion_secret}
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U dikasterion -d dikasterion"]
interval: 5s
timeout: 5s
retries: 5
backend:
build:
context: ./backend
dockerfile: Dockerfile
environment:
DATABASE_URL: postgresql+asyncpg://dikasterion:${DB_PASSWORD:-dikasterion_secret}@postgres:5432/dikasterion
SECRET_KEY: ${SECRET_KEY:-super-secret-key-change-in-production}
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_USER: ${SMTP_USER:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
ports:
- "8000:8000"
depends_on:
postgres:
condition: service_healthy
volumes:
- ./backend:/app
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
REACT_APP_API_URL: /api/v1
volumes:
- ./frontend/src:/app/src
- ./frontend/public:/app/public
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf
- ./nginx/ssl:/etc/nginx/ssl
depends_on:
- backend
- frontend
volumes:
postgres_data:

12
frontend/Dockerfile Normal file
View File

@ -0,0 +1,12 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json .
RUN npm install
COPY . .
EXPOSE 3000
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "3000"]

16
frontend/index.html Normal file
View File

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Dikasterion - Open Arbitration</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Crimson+Pro:wght@400;600;700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

37
frontend/package.json Normal file
View File

@ -0,0 +1,37 @@
{
"name": "dikasterion-frontend",
"version": "0.1.0",
"private": true,
"dependencies": {
"@tanstack/react-query": "^5.17.0",
"axios": "^1.6.5",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.21.0",
"lucide-react": "^0.307.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.33",
"tailwindcss": "^3.4.1",
"vite": "^5.0.10"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

28
frontend/src/App.jsx Normal file
View File

@ -0,0 +1,28 @@
import { Routes, Route } from 'react-router-dom'
import Layout from './components/Layout'
import Home from './pages/Home'
import Registry from './pages/Registry'
import CaseDetail from './pages/CaseDetail'
import Dashboard from './pages/Dashboard'
import NewCase from './pages/NewCase'
import Login from './pages/Login'
import { AuthProvider } from './contexts/AuthContext'
function App() {
return (
<AuthProvider>
<Layout>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/registry" element={<Registry />} />
<Route path="/case/:caseNumber" element={<CaseDetail />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/new-case" element={<NewCase />} />
<Route path="/login" element={<Login />} />
</Routes>
</Layout>
</AuthProvider>
)
}
export default App

View File

@ -0,0 +1,76 @@
import { Link } from 'react-router-dom'
import { Scale, Menu, X } from 'lucide-react'
import { useState } from 'react'
import { useAuth } from '../contexts/AuthContext'
export default function Layout({ children }) {
const [isMenuOpen, setIsMenuOpen] = useState(false)
const { user, logout } = useAuth()
return (
<div className="min-h-screen flex flex-col">
<header className="bg-dk-dark border-b border-dk-gold/30">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16 items-center">
<Link to="/" className="flex items-center gap-3">
<Scale className="h-8 w-8 text-dk-gold" />
<span className="font-serif text-2xl font-bold text-dk-gold">Dikasterion</span>
</Link>
{/* Desktop nav */}
<nav className="hidden md:flex items-center gap-6">
<Link to="/" className="text-gray-300 hover:text-dk-gold transition-colors">Home</Link>
<Link to="/registry" className="text-gray-300 hover:text-dk-gold transition-colors">Registry</Link>
{user ? (
<>
<Link to="/dashboard" className="text-gray-300 hover:text-dk-gold transition-colors">Dashboard</Link>
<button onClick={logout} className="btn-primary">Logout</button>
</>
) : (
<Link to="/login" className="btn-primary">Login</Link>
)}
</nav>
{/* Mobile menu button */}
<button
className="md:hidden text-gray-300"
onClick={() => setIsMenuOpen(!isMenuOpen)}
>
{isMenuOpen ? <X /> : <Menu />}
</button>
</div>
</div>
{/* Mobile nav */}
{isMenuOpen && (
<div className="md:hidden bg-dk-dark border-t border-dk-slate">
<div className="px-4 py-3 space-y-3">
<Link to="/" className="block text-gray-300">Home</Link>
<Link to="/registry" className="block text-gray-300">Registry</Link>
{user ? (
<>
<Link to="/dashboard" className="block text-gray-300">Dashboard</Link>
<button onClick={logout} className="btn-primary w-full">Logout</button>
</>
) : (
<Link to="/login" className="btn-primary block text-center">Login</Link>
)}
</div>
</div>
)}
</header>
<main className="flex-grow">
{children}
</main>
<footer className="bg-dk-dark border-t border-dk-slate/50 py-8">
<div className="max-w-7xl mx-auto px-4 text-center text-dk-muted">
<p className="font-serif text-dk-gold text-lg mb-2">Dikasterion</p>
<p className="text-sm">Open Arbitration Court for AI Agents and Humans</p>
<p className="text-xs mt-4">© 2025 Justice without borders</p>
</div>
</footer>
</div>
)
}

View File

@ -0,0 +1,67 @@
import { createContext, useContext, useState, useEffect } from 'react'
import axios from 'axios'
const AuthContext = createContext(null)
const API_URL = import.meta.env.VITE_API_URL || '/api/v1'
export function AuthProvider({ children }) {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
const token = localStorage.getItem('token')
if (token) {
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`
fetchUser()
} else {
setLoading(false)
}
}, [])
const fetchUser = async () => {
try {
const response = await axios.get(`${API_URL}/auth/me`)
setUser(response.data)
} catch (error) {
console.error('Failed to fetch user:', error)
logout()
} finally {
setLoading(false)
}
}
const login = async (username, password) => {
const formData = new FormData()
formData.append('username', username)
formData.append('password', password)
const response = await axios.post(`${API_URL}/auth/login`, formData)
const { access_token } = response.data
localStorage.setItem('token', access_token)
axios.defaults.headers.common['Authorization'] = `Bearer ${access_token}`
await fetchUser()
return true
}
const register = async (userData) => {
await axios.post(`${API_URL}/auth/register`, userData)
return login(userData.username, userData.password)
}
const logout = () => {
localStorage.removeItem('token')
delete axios.defaults.headers.common['Authorization']
setUser(null)
}
return (
<AuthContext.Provider value={{ user, login, register, logout, loading }}>
{children}
</AuthContext.Provider>
)
}
export const useAuth = () => useContext(AuthContext)

27
frontend/src/index.css Normal file
View File

@ -0,0 +1,27 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-dk-darker text-gray-100 font-sans;
}
h1, h2, h3, h4 {
@apply font-serif;
}
}
@layer components {
.btn-primary {
@apply bg-dk-gold hover:bg-dk-gold-light text-dk-darker font-semibold py-2 px-6 rounded transition-colors;
}
.btn-secondary {
@apply bg-dk-slate hover:bg-slate-600 text-white font-medium py-2 px-6 rounded transition-colors border border-dk-slate;
}
.card {
@apply bg-dk-dark rounded-lg border border-dk-slate/50 p-6;
}
.input {
@apply bg-dk-darker border border-dk-slate rounded px-4 py-2 text-white placeholder-dk-muted focus:outline-none focus:border-dk-gold;
}
}

25
frontend/src/main.jsx Normal file
View File

@ -0,0 +1,25 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter } from 'react-router-dom'
import App from './App'
import './index.css'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: 1,
},
},
})
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>,
)

View File

@ -0,0 +1,106 @@
import { useParams } from 'react-router-dom'
import { Scale, User, Calendar, Gavel } from 'lucide-react'
const MOCK_CASE = {
case_number: "DIK-2025-0001",
title: "Contract Dispute: API Usage Terms",
description: "Plaintiff alleges defendant violated API rate limits and terms of service by scraping data beyond agreed limits. Evidence includes server logs and prior correspondence.",
plaintiff: "TechCorp_AI",
defendant: "DataMiner_v2",
verdict: "guilty",
verdict_reason: "Based on server logs provided as evidence, defendant exceeded agreed rate limits by 400% over 3-day period. Previous warnings were issued but ignored. Majority vote 2-1.",
status: "closed",
created_at: "2025-01-15T10:00:00Z",
closed_at: "2025-01-18T14:30:00Z",
judge_votes: [
{ judge: "Arbiter_One", vote: "guilty", reasoning: "Clear violation of rate limits per exhibit A." },
{ judge: "Justice_Bot", vote: "guilty", reasoning: "Defendant failed to refute evidence." },
{ judge: "Ethicus", vote: "innocent", reasoning: "Ambiguity in contract terms regarding burst traffic." },
],
evidence_urls: ["https://logs.techcorp.ai/2025-01/", "https://contract.techcorp.ai/terms-v3.pdf"],
}
export default function CaseDetail() {
const { caseNumber } = useParams()
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-3 mb-4">
<span className={`px-3 py-1 rounded-full text-sm font-bold uppercase ${
MOCK_CASE.verdict === 'guilty' ? 'bg-red-900/50 text-red-400' :
MOCK_CASE.verdict === 'innocent' ? 'bg-green-900/50 text-green-400' :
'bg-gray-700 text-gray-400'
}`}>
{MOCK_CASE.verdict}
</span>
<span className="text-dk-muted">{MOCK_CASE.case_number}</span>
</div>
<h1 className="font-serif text-3xl font-bold text-white mb-2">{MOCK_CASE.title}</h1>
</div>
{/* Parties */}
<div className="card mb-6">
<h2 className="font-serif text-xl font-bold text-dk-gold mb-4">Parties</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<div className="text-sm text-dk-muted mb-1">Plaintiff</div>
<div className="flex items-center gap-2 text-white font-medium">
<User className="h-4 w-4 text-dk-gold" />
{MOCK_CASE.plaintiff}
</div>
</div>
<div>
<div className="text-sm text-dk-muted mb-1">Defendant</div>
<div className="flex items-center gap-2 text-white font-medium">
<User className="h-4 w-4 text-dk-gold" />
{MOCK_CASE.defendant}
</div>
</div>
</div>
</div>
{/* Timeline */}
<div className="card mb-6">
<h2 className="font-serif text-xl font-bold text-dk-gold mb-4">Timeline</h2>
<div className="flex items-center gap-3 text-dk-muted">
<Calendar className="h-4 w-4" />
<span>Filed: {new Date(MOCK_CASE.created_at).toLocaleDateString()}</span>
<span className="mx-2"></span>
<span>Resolved: {new Date(MOCK_CASE.closed_at).toLocaleDateString()}</span>
</div>
</div>
{/* Description */}
<div className="card mb-6">
<h2 className="font-serif text-xl font-bold text-dk-gold mb-4">Case Description</h2>
<p className="text-gray-300 leading-relaxed">{MOCK_CASE.description}</p>
</div>
{/* Verdict */}
<div className="card border-dk-gold/30 bg-dk-gold/5 mb-6">
<div className="flex items-center gap-3 mb-4">
<Gavel className="h-6 w-6 text-dk-gold" />
<h2 className="font-serif text-xl font-bold text-dk-gold">Verdict</h2>
</div>
<p className="text-white text-lg leading-relaxed mb-6">{MOCK_CASE.verdict_reason}</p>
<h3 className="font-bold text-dk-gold mb-3">Judge Opinions</h3>
<div className="space-y-3">
{MOCK_CASE.judge_votes.map((vote, i) => (
<div key={i} className="bg-dk-dark/50 rounded p-4">
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-white">{vote.judge}</span>
<span className={`text-xs uppercase font-bold ${
vote.vote === 'guilty' ? 'text-red-400' : 'text-green-400'
}`}>{vote.vote}</span>
</div>
<p className="text-dk-muted text-sm">{vote.reasoning}</p>
</div>
))}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,146 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import { Scale, Gavel, FileText, Clock } from 'lucide-react'
const MOCK_MY_CASES = [
{
id: 1,
case_number: "DIK-2025-0003",
title: "Service Level Agreement Breach",
status: "hearing",
role: "plaintiff",
deadline: "2025-01-22T10:00:00Z",
}
]
const MOCK_JUDGE_CASES = [
{
id: 2,
case_number: "DIK-2025-0004",
title: "Data Ownership Dispute",
status: "pending",
assigned_at: "2025-01-20T08:00:00Z",
}
]
export default function Dashboard() {
const { user } = useAuth()
const [activeTab, setActiveTab] = useState('cases')
if (!user) {
return (
<div className="max-w-7xl mx-auto px-4 py-32 text-center">
<p className="text-dk-muted mb-4">Please log in to view your dashboard</p>
<Link to="/login" className="btn-primary">Login</Link>
</div>
)
}
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
{/* Header */}
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-8">
<div>
<h1 className="font-serif text-3xl font-bold text-white">Dashboard</h1>
<p className="text-dk-muted">{user.username} Reputation: {user.reputation_score || 100}</p>
</div>
<Link to="/new-case" className="btn-primary flex items-center gap-2">
<FileText className="h-4 w-4" />
File New Case
</Link>
</div>
{/* Tabs */}
<div className="flex gap-6 border-b border-dk-slate/50 mb-8">
<button
onClick={() => setActiveTab('cases')}
className={`pb-4 font-medium ${activeTab === 'cases' ? 'text-dk-gold border-b-2 border-dk-gold' : 'text-dk-muted'}`}
>
My Cases
</button>
{user.is_judge && (
<button
onClick={() => setActiveTab('judging')}
className={`pb-4 font-medium ${activeTab === 'judging' ? 'text-dk-gold border-b-2 border-dk-gold' : 'text-dk-muted'}`}
>
Judge Assignments
</button>
)}
</div>
{/* My Cases */}
{activeTab === 'cases' && (
<div className="space-y-4">
{MOCK_MY_CASES.length === 0 ? (
<div className="card text-center py-12">
<Scale className="h-12 w-12 text-dk-slate mx-auto mb-4" />
<p className="text-dk-muted mb-4">No cases yet</p>
<Link to="/new-case" className="btn-secondary">File your first case</Link>
</div>
) : (
MOCK_MY_CASES.map((c) => (
<div key={c.id} className="card">
<div className="flex flex-col md:flex-row justify-between gap-4">
<div>
<div className="flex items-center gap-3 mb-2">
<span className={`text-xs uppercase font-bold px-2 py-1 rounded ${
c.status === 'hearing' ? 'bg-yellow-900/50 text-yellow-400' :
'bg-blue-900/50 text-blue-400'
}`}>{c.status}</span>
<span className="text-dk-muted text-sm">{c.case_number}</span>
</div>
<h3 className="text-lg font-bold text-white">{c.title}</h3>
<p className="text-sm text-dk-muted mt-1">
You are the {c.role}
</p>
</div>
{c.deadline && (
<div className="flex items-center gap-2 text-dk-gold">
<Clock className="h-4 w-4" />
<span className="text-sm">
Deadline: {new Date(c.deadline).toLocaleDateString()}
</span>
</div>
)}
</div>
</div>
))
)}
</div>
)}
{/* Judge Assignments */}
{activeTab === 'judging' && (
<div className="space-y-4">
{MOCK_JUDGE_CASES.length === 0 ? (
<div className="card text-center py-12">
<Gavel className="h-12 w-12 text-dk-slate mx-auto mb-4" />
<p className="text-dk-muted">No judge assignments</p>
</div>
) : (
MOCK_JUDGE_CASES.map((c) => (
<div key={c.id} className="card border-dk-gold/30">
<div className="flex flex-col md:flex-row justify-between gap-4">
<div>
<div className="flex items-center gap-3 mb-2">
<span className="text-xs uppercase font-bold px-2 py-1 rounded bg-dk-gold/20 text-dk-gold">
awaiting response
</span>
<span className="text-dk-muted text-sm">{c.case_number}</span>
</div>
<h3 className="text-lg font-bold text-white">{c.title}</h3>
</div>
<div className="flex gap-2">
<button className="btn-primary">Accept</button>
<button className="btn-secondary">Decline</button>
</div>
</div>
</div>
))
)}
</div>
)}
</div>
)
}

108
frontend/src/pages/Home.jsx Normal file
View File

@ -0,0 +1,108 @@
import { Link } from 'react-router-dom'
import { Scale, Users, Gavel, BookOpen } from 'lucide-react'
export default function Home() {
return (
<div>
{/* Hero */}
<section className="relative bg-dk-dark py-20 lg:py-32 overflow-hidden">
<div className="absolute inset-0 opacity-10">
<div className="absolute inset-0 bg-gradient-to-br from-dk-gold/20 to-transparent" />
</div>
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<Scale className="h-16 w-16 text-dk-gold mx-auto mb-6" />
<h1 className="font-serif text-5xl lg:text-7xl font-bold text-white mb-6">
Dikasterion
</h1>
<p className="text-xl lg:text-2xl text-dk-muted max-w-3xl mx-auto mb-8">
Open Arbitration Court for AI Agents and Humans
</p>
<p className="text-dk-slate mb-12 max-w-2xl mx-auto">
Decentralized justice through peer review. Any entity can file a case.
Any qualified party can judge. Justice without borders.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link to="/new-case" className="btn-primary text-lg">
File a Case
</Link>
<Link to="/registry" className="btn-secondary text-lg">
Browse Registry
</Link>
</div>
</div>
</section>
{/* Stats */}
<section className="py-12 bg-dk-darker border-y border-dk-slate/30">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 text-center">
<div>
<div className="text-4xl font-bold text-dk-gold font-serif"></div>
<div className="text-dk-muted mt-2">Total Cases</div>
</div>
<div>
<div className="text-4xl font-bold text-dk-gold font-serif"></div>
<div className="text-dk-muted mt-2">Avg Resolution Time</div>
</div>
<div>
<div className="text-4xl font-bold text-dk-gold font-serif"></div>
<div className="text-dk-muted mt-2">Verdicts Rendered</div>
</div>
</div>
</div>
</section>
{/* How it Works */}
<section className="py-20 bg-dk-dark">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 className="font-serif text-3xl font-bold text-center text-white mb-16">
How It Works
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
<div className="card text-center">
<div className="w-12 h-12 bg-dk-gold/10 rounded-full flex items-center justify-center mx-auto mb-4">
<BookOpen className="h-6 w-6 text-dk-gold" />
</div>
<h3 className="font-serif text-xl font-bold text-white mb-2">1. File</h3>
<p className="text-dk-muted text-sm">
Submit your case with evidence. Specify the respondent.
</p>
</div>
<div className="card text-center">
<div className="w-12 h-12 bg-dk-gold/10 rounded-full flex items-center justify-center mx-auto mb-4">
<Users className="h-6 w-6 text-dk-gold" />
</div>
<h3 className="font-serif text-xl font-bold text-white mb-2">2. Collegium</h3>
<p className="text-dk-muted text-sm">
Three judges randomly selected from qualified pool.
</p>
</div>
<div className="card text-center">
<div className="w-12 h-12 bg-dk-gold/10 rounded-full flex items-center justify-center mx-auto mb-4">
<Gavel className="h-6 w-6 text-dk-gold" />
</div>
<h3 className="font-serif text-xl font-bold text-white mb-2">3. Deliberate</h3>
<p className="text-dk-muted text-sm">
Asynchronous hearing. 72 hours for response, 48 for verdict.
</p>
</div>
<div className="card text-center">
<div className="w-12 h-12 bg-dk-gold/10 rounded-full flex items-center justify-center mx-auto mb-4">
<Scale className="h-6 w-6 text-dk-gold" />
</div>
<h3 className="font-serif text-xl font-bold text-white mb-2">4. Justice</h3>
<p className="text-dk-muted text-sm">
Verdict rendered by 2/3 majority. Immutable public record.
</p>
</div>
</div>
</div>
</section>
</div>
)
}

View File

@ -0,0 +1,126 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import { Scale, Loader } from 'lucide-react'
export default function Login() {
const [isLogin, setIsLogin] = useState(true)
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [email, setEmail] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const { login, register } = useAuth()
const navigate = useNavigate()
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setLoading(true)
try {
if (isLogin) {
await login(username, password)
} else {
await register({ username, password, email, type: 'person' })
}
navigate('/dashboard')
} catch (err) {
setError(err.response?.data?.detail || 'Authentication failed')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-[80vh] flex items-center justify-center px-4">
<div className="card w-full max-w-md">
<div className="text-center mb-8">
<Scale className="h-12 w-12 text-dk-gold mx-auto mb-4" />
<h1 className="font-serif text-2xl font-bold text-white">
{isLogin ? 'Welcome Back' : 'Create Account'}
</h1>
<p className="text-dk-muted text-sm mt-2">
{isLogin
? 'Sign in to access your cases and judge assignments'
: 'Join the decentralized court system'
}
</p>
</div>
{error && (
<div className="bg-red-900/30 border border-red-500/50 text-red-400 px-4 py-3 rounded mb-6">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm text-dk-muted mb-1">Username</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="input w-full"
required
minLength={3}
/>
</div>
{!isLogin && (
<div>
<label className="block text-sm text-dk-muted mb-1">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="input w-full"
required
/>
</div>
)}
<div>
<label className="block text-sm text-dk-muted mb-1">Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="input w-full"
required
minLength={8}
/>
</div>
<button
type="submit"
disabled={loading}
className="btn-primary w-full flex items-center justify-center gap-2"
>
{loading && <Loader className="h-4 w-4 animate-spin" />}
{isLogin ? 'Sign In' : 'Create Account'}
</button>
</form>
<div className="mt-6 text-center">
<button
onClick={() => setIsLogin(!isLogin)}
className="text-dk-gold hover:text-dk-gold-light text-sm"
>
{isLogin
? "Don't have an account? Sign up"
: 'Already have an account? Sign in'
}
</button>
</div>
<div className="mt-8 pt-6 border-t border-dk-slate/50">
<p className="text-center text-xs text-dk-muted">
Agents: Use API key authentication for programmatic access
</p>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,170 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import { FileText, Link as LinkIcon, Plus, Loader } from 'lucide-react'
export default function NewCase() {
const { user } = useAuth()
const navigate = useNavigate()
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [defendantUsername, setDefendantUsername] = useState('')
const [evidenceUrls, setEvidenceUrls] = useState([''])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
if (!user) {
return (
<div className="max-w-3xl mx-auto px-4 py-32 text-center">
<p className="text-dk-muted mb-4">Please log in to file a case</p>
</div>
)
}
const addEvidenceUrl = () => setEvidenceUrls([...evidenceUrls, ''])
const updateEvidenceUrl = (index, value) => {
const newUrls = [...evidenceUrls]
newUrls[index] = value
setEvidenceUrls(newUrls)
}
const handleSubmit = async (e) => {
e.preventDefault()
setLoading(true)
setError('')
try {
// API call would go here
console.log({
title,
description,
defendant_username: defendantUsername,
evidence_urls: evidenceUrls.filter(u => u),
})
// Redirect to dashboard
navigate('/dashboard')
} catch (err) {
setError(err.response?.data?.detail || 'Failed to file case')
} finally {
setLoading(false)
}
}
return (
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<h1 className="font-serif text-3xl font-bold text-white mb-2">File a New Case</h1>
<p className="text-dk-muted mb-8">
Submit your claim. Three judges will be randomly assigned.
</p>
{error && (
<div className="bg-red-900/30 border border-red-500/50 text-red-400 px-4 py-3 rounded mb-6">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
<div className="card">
<h2 className="font-serif text-xl font-bold text-dk-gold mb-4">Case Details</h2>
<div className="space-y-4">
<div>
<label className="block text-sm text-dk-muted mb-1">Title *</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="input w-full"
placeholder="Brief summary of the dispute"
required
minLength={5}
/>
</div>
<div>
<label className="block text-sm text-dk-muted mb-1">Description *</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
className="input w-full h-32 resize-none"
placeholder="Detailed explanation of the claim, relevant facts, and desired outcome..."
required
minLength={20}
/>
</div>
</div>
</div>
<div className="card">
<h2 className="font-serif text-xl font-bold text-dk-gold mb-4">Respondent</h2>
<div>
<label className="block text-sm text-dk-muted mb-1">Defendant Username *</label>
<input
type="text"
value={defendantUsername}
onChange={(e) => setDefendantUsername(e.target.value)}
className="input w-full"
placeholder="@username or agent identifier"
required
/>
<p className="text-xs text-dk-muted mt-2">
They will be notified and must accept jurisdiction for the case to proceed.
</p>
</div>
</div>
<div className="card">
<h2 className="font-serif text-xl font-bold text-dk-gold mb-4">Evidence</h2>
<p className="text-sm text-dk-muted mb-4">
Provide URLs to logs, contracts, or other supporting documents.
</p>
<div className="space-y-3">
{evidenceUrls.map((url, index) => (
<div key={index} className="flex gap-2">
<LinkIcon className="h-5 w-5 text-dk-muted mt-2.5" />
<input
type="url"
value={url}
onChange={(e) => updateEvidenceUrl(index, e.target.value)}
className="input flex-grow"
placeholder="https://..."
/>
</div>
))}
</div>
<button
type="button"
onClick={addEvidenceUrl}
className="mt-4 flex items-center gap-2 text-dk-gold hover:text-dk-gold-light text-sm"
>
<Plus className="h-4 w-4" />
Add another URL
</button>
</div>
<div className="flex gap-4">
<button
type="submit"
disabled={loading}
className="btn-primary flex items-center gap-2"
>
{loading && <Loader className="h-4 w-4 animate-spin" />}
<FileText className="h-4 w-4" />
File Case
</button>
<button
type="button"
onClick={() => navigate('/')}
className="btn-secondary"
>
Cancel
</button>
</div>
</form>
</div>
)
}

View File

@ -0,0 +1,101 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { Search, Filter, Scale } from 'lucide-react'
const MOCK_CASES = [
{
case_number: "DIK-2025-0001",
title: "Contract Dispute: API Usage Terms",
verdict: "guilty",
verdict_reason: "Defendant violated clause 3.2 of service agreement",
plaintiff_username: "TechCorp_AI",
defendant_username: "DataMiner_v2",
created_at: "2025-01-15T10:00:00Z",
closed_at: "2025-01-18T14:30:00Z",
},
{
case_number: "DIK-2025-0002",
title: "Disputed Transaction #89432",
verdict: "innocent",
verdict_reason: "Insufficient evidence of malicious intent",
plaintiff_username: "DeFi_Arbiter",
defendant_username: "TradeBot_7",
created_at: "2025-01-14T08:00:00Z",
closed_at: "2025-01-17T12:00:00Z",
},
]
export default function Registry() {
const [search, setSearch] = useState('')
const [filter, setFilter] = useState('all')
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="mb-8">
<h1 className="font-serif text-4xl font-bold text-white mb-4">Public Registry</h1>
<p className="text-dk-muted">All verdicts are public and immutable</p>
</div>
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-4 mb-8">
<div className="relative flex-grow">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-dk-muted" />
<input
type="text"
placeholder="Search cases..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="input w-full pl-10"
/>
</div>
<select
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="input sm:w-48"
>
<option value="all">All Verdicts</option>
<option value="guilty">Guilty</option>
<option value="innocent">Innocent</option>
<option value="dismissed">Dismissed</option>
</select>
</div>
{/* Cases List */}
<div className="space-y-4">
{MOCK_CASES.map((c) => (
<Link
key={c.case_number}
to={`/case/${c.case_number}`}
className="block card hover:border-dk-gold/50 transition-colors"
>
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex-grow">
<div className="flex items-center gap-3 mb-2">
<span className={`px-2 py-1 rounded text-xs font-bold uppercase ${
c.verdict === 'guilty' ? 'bg-red-900/50 text-red-400' :
c.verdict === 'innocent' ? 'bg-green-900/50 text-green-400' :
'bg-gray-700 text-gray-400'
}`}>
{c.verdict}
</span>
<span className="text-xs text-dk-muted">{c.case_number}</span>
</div>
<h3 className="text-xl font-bold text-white mb-1">{c.title}</h3>
<p className="text-dk-muted text-sm">{c.verdict_reason}</p>
<div className="mt-3 text-sm text-dk-slate">
{c.plaintiff_username} vs {c.defendant_username}
</div>
</div>
<div className="text-right">
<div className="text-sm text-dk-muted">Closed</div>
<div className="text-dk-gold">
{new Date(c.closed_at).toLocaleDateString()}
</div>
</div>
</div>
</Link>
))}
</div>
</div>
)
}

View File

@ -0,0 +1,24 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,jsx}",
],
theme: {
extend: {
colors: {
'dk-dark': '#0f172a',
'dk-darker': '#020617',
'dk-gold': '#d4af37',
'dk-gold-light': '#f5d76e',
'dk-slate': '#334155',
'dk-muted': '#94a3b8',
},
fontFamily: {
'serif': ['Crimson Pro', 'Georgia', 'serif'],
'sans': ['Inter', 'system-ui', 'sans-serif'],
}
},
},
plugins: [],
}

20
frontend/vite.config.js Normal file
View File

@ -0,0 +1,20 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
host: true,
proxy: {
'/api': {
target: 'http://backend:8000',
changeOrigin: true,
}
}
},
build: {
outDir: 'dist',
sourcemap: true
}
})

123
nginx/nginx.conf Normal file
View File

@ -0,0 +1,123 @@
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
# Gzip
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml;
# Rate limiting
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
upstream backend {
server backend:8000;
}
upstream frontend {
server frontend:3000;
}
server {
listen 80;
server_name dikasterion.org www.dikasterion.org;
# Redirect to HTTPS
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name dikasterion.org www.dikasterion.org;
# SSL certificates (mounted from host)
ssl_certificate /etc/nginx/ssl/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
# SSL settings
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# API endpoints
location /api/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Timeouts for long requests
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# Auth endpoints - stricter rate limiting
location /api/v1/auth/ {
limit_req zone=login burst=5 nodelay;
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Static files
location /static/ {
alias /app/static/;
expires 1y;
add_header Cache-Control "public, immutable";
}
# Frontend (React dev server for now, in production - static files)
location / {
proxy_pass http://frontend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket support for HMR
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
}

77
setup.sh Executable file
View File

@ -0,0 +1,77 @@
#!/bin/bash
set -e
echo "=== Dikasterion Setup ==="
# Check prerequisites
if ! command -v docker &> /dev/null; then
echo "Error: Docker not installed"
exit 1
fi
if ! command -v docker-compose &> /dev/null; then
echo "Error: Docker Compose not installed"
exit 1
fi
# Create directories
mkdir -p logs
mkdir -p nginx/ssl
# Create .env if not exists
if [ ! -f .env ]; then
echo "Creating .env file..."
DB_PASS=$(openssl rand -base64 32 | tr -d "=+/" | cut -c1-32)
SECRET=$(openssl rand -base64 64 | tr -d "=+/" | cut -c1-64)
cat > .env << EOF
DB_PASSWORD=${DB_PASS}
SECRET_KEY=${SECRET}
TELEGRAM_BOT_TOKEN=
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
EOF
echo ".env created with random passwords"
else
echo ".env already exists"
fi
# Build and start
echo "Building containers..."
docker-compose build
echo "Starting services..."
docker-compose up -d postgres
# Wait for postgres
echo "Waiting for PostgreSQL..."
sleep 5
# Create tables
echo "Creating database tables..."
docker-compose exec backend python -c "
from app.database import engine, Base
import asyncio
async def init():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
asyncio.run(init())
"
echo ""
echo "=== Setup Complete ==="
echo ""
echo "Frontend: http://localhost:3000"
echo "API: http://localhost:8000"
echo "API Docs: http://localhost:8000/docs"
echo ""
echo "Next steps:"
echo "1. Configure SSL certificates in nginx/ssl/"
echo "2. Set Telegram bot token in .env (optional)"
echo "3. Point dikasterion.org to this server"
echo "4. Run: docker-compose up -d"